keycloak-aplcache

Merge pull request #1083 from velias/KEYCLOAK-1046 KEYCLOAK-1046

3/24/2015 9:31:36 AM

Changes

social/pom.xml 1(+1 -0)

Details

diff --git a/broker/oidc/src/main/java/org/keycloak/broker/oidc/util/SimpleHttp.java b/broker/oidc/src/main/java/org/keycloak/broker/oidc/util/SimpleHttp.java
index 870e951..c9050b8 100644
--- a/broker/oidc/src/main/java/org/keycloak/broker/oidc/util/SimpleHttp.java
+++ b/broker/oidc/src/main/java/org/keycloak/broker/oidc/util/SimpleHttp.java
@@ -13,9 +13,11 @@ import java.net.URL;
 import java.net.URLEncoder;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.zip.GZIPInputStream;
 
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
+ * @author Vlastimil Elias (velias at redhat dot com)
  */
 public class SimpleHttp {
 
@@ -116,7 +118,11 @@ public class SimpleHttp {
                 connection.setDoOutput(false);
             }
 
+            String ce = connection.getHeaderField("Content-Encoding");
             is = connection.getInputStream();
+            if ("gzip".equals(ce)) {
+              is = new GZIPInputStream(is);
+	          }
             return toString(is);
         } finally {
             if (os != null) {
diff --git a/dependencies/server-all/pom.xml b/dependencies/server-all/pom.xml
index a7bd522..2c1f6f3 100755
--- a/dependencies/server-all/pom.xml
+++ b/dependencies/server-all/pom.xml
@@ -127,6 +127,11 @@
             <artifactId>keycloak-social-linkedin</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-social-stackoverflow</artifactId>
+            <version>${project.version}</version>
+        </dependency>
 
         <!-- ldap federation api -->
         <dependency>
diff --git a/distribution/modules/build.xml b/distribution/modules/build.xml
index ad0f9e7..80ff507 100755
--- a/distribution/modules/build.xml
+++ b/distribution/modules/build.xml
@@ -239,6 +239,10 @@
     	  <module-def name="org.keycloak.keycloak-social-linkedin">
     	      <maven-resource group="org.keycloak" artifact="keycloak-social-linkedin"/>
     	  </module-def>
+    	
+      	<module-def name="org.keycloak.keycloak-social-stackoverflow">
+    	      <maven-resource group="org.keycloak" artifact="keycloak-social-stackoverflow"/>
+    	  </module-def>
 
         <module-def name="org.keycloak.keycloak-kerberos-federation">
             <maven-resource group="org.keycloak" artifact="keycloak-kerberos-federation"/>
diff --git a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-server/main/module.xml b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-server/main/module.xml
index f8a251d..f553d24 100755
--- a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-server/main/module.xml
+++ b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-server/main/module.xml
@@ -58,6 +58,7 @@
         <module name="org.keycloak.keycloak-social-google" services="import"/>
         <module name="org.keycloak.keycloak-social-twitter" services="import"/>
         <module name="org.keycloak.keycloak-social-linkedin" services="import"/>
+        <module name="org.keycloak.keycloak-social-stackoverflow" services="import"/>
         <module name="org.keycloak.keycloak-subsystem" services="import"/>
         <module name="org.keycloak.keycloak-timer-api" services="import"/>
         <module name="org.keycloak.keycloak-timer-basic" services="import"/>
diff --git a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-services/main/module.xml b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-services/main/module.xml
index 6166b9b..86e86f4 100755
--- a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-services/main/module.xml
+++ b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-services/main/module.xml
@@ -61,6 +61,7 @@
         <module name="org.keycloak.keycloak-social-google" services="import"/>
         <module name="org.keycloak.keycloak-social-twitter" services="import"/>
         <module name="org.keycloak.keycloak-social-linkedin" services="import"/>
+        <module name="org.keycloak.keycloak-social-stackoverflow" services="import"/>
         <module name="org.keycloak.keycloak-timer-api" services="import"/>
         <module name="org.keycloak.keycloak-timer-basic" services="import"/>
 
diff --git a/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-social-stackoverflow/main/module.xml b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-social-stackoverflow/main/module.xml
new file mode 100755
index 0000000..6ddd2a4
--- /dev/null
+++ b/distribution/modules/src/main/resources/modules/org/keycloak/keycloak-social-stackoverflow/main/module.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+
+
+<module xmlns="urn:jboss:module:1.1" name="org.keycloak.keycloak-social-stackoverflow">
+    <resources>
+        <!-- Insert resources here -->
+    </resources>
+    <dependencies>
+        <module name="org.keycloak.keycloak-core"/>
+        <module name="org.keycloak.keycloak-social-core"/>
+        <module name="org.keycloak.keycloak-broker-core"/>
+        <module name="org.keycloak.keycloak-broker-oidc"/>
+        <module name="org.keycloak.keycloak-model-api"/>
+        <module name="org.jboss.logging"/>
+        <module name="javax.api"/>
+        <module name="org.codehaus.jackson.jackson-core-asl"/>
+        <module name="org.codehaus.jackson.jackson-mapper-asl"/>
+        <module name="org.codehaus.jackson.jackson-xc"/>
+    </dependencies>
+
+</module>
diff --git a/distribution/subsystem-war/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/distribution/subsystem-war/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
index 6a97ca5..6caa2c8 100755
--- a/distribution/subsystem-war/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
+++ b/distribution/subsystem-war/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
@@ -51,6 +51,7 @@
             <module name="org.keycloak.keycloak-social-google" services="import"/>
             <module name="org.keycloak.keycloak-social-twitter" services="import"/>
             <module name="org.keycloak.keycloak-social-linkedin" services="import"/>
+            <module name="org.keycloak.keycloak-social-stackoverflow" services="import"/>
             <module name="org.keycloak.keycloak-timer-api" services="import"/>
             <module name="org.keycloak.keycloak-timer-basic" services="import"/>
             <module name="org.hibernate" services="import"/>
diff --git a/docbook/reference/en/en-US/modules/identity-broker.xml b/docbook/reference/en/en-US/modules/identity-broker.xml
index 3a74b73..5ebbbe2 100755
--- a/docbook/reference/en/en-US/modules/identity-broker.xml
+++ b/docbook/reference/en/en-US/modules/identity-broker.xml
@@ -807,6 +807,78 @@
                     </tbody>
                 </tgroup>
             </table>
+        </section>
+        <section>
+            <title>StackOverflow</title>
+            <para>
+                To enable login with StackOverflow you first have to register an OAuth application on
+                <ulink url="https://stackapps.com/">StackApps</ulink>. Then you need to copy the client id, secret and key into the Keycloak Admin Console.
+            </para>
+            <para>
+                Let's see first how to create an application with StackOverflow.
+            </para>
+            <orderedlist>
+                <listitem>
+                    <para>
+                        Go to <ulink url="http://stackapps.com/apps/oauth/register">registering your application on Stack Apps</ulink> url and login here. 
+                        Use any value for <literal>Application Name</literal>, <literal>Application Website</literal> and <literal>Description</literal> you want.
+                        Set <literal>OAuth Domain</literal> to the domain where your Keycloak instance runs.
+                        Click the <literal>Register Your Application</literal> button.
+                    </para>
+                </listitem>
+                <listitem>
+                    <para>
+                        Copy <literal>Client Id</literal>, <literal>Client Secret</literal> and <literal>Key</literal> from the shown page.
+                    </para>
+                </listitem>
+            </orderedlist>
+            <para>
+                Now that you have the client id, secret and key, you can proceed with the creation of a StackOverflow Identity Provider in Keycloak. As follows:
+            </para>
+            <orderedlist>
+                <listitem>
+                    <para>
+                        Select the <literal>StackOverflow</literal> identity provider from the drop-down box on the top right corner of the identity providers table in Keycloak's Admin Console. You should be presented with a specific page to configure the selected provided.
+                    </para>
+                </listitem>
+                <listitem>
+                    <para>
+                        Copy the client id, client secret and key to their corresponding fields in the Keycloak Admin Console. Click <literal>Save</literal>.
+                    </para>
+                </listitem>
+            </orderedlist>
+            <para>
+                That is it! This pretty much what you need to do in order to setup this identity provider.
+            </para>
+            <para>
+                The table below lists some additional configuration options you may use when configuring this provider.
+            </para>
+            <table>
+                <title>Configuration Options</title>
+                <tgroup align="left" cols="2">
+                    <thead>
+                        <row>
+                            <entry>
+                                Configuration
+                            </entry>
+                            <entry>
+                                Description
+                            </entry>
+                        </row>
+                    </thead>
+                    <tbody valign="top">
+                        <row>
+                            <entry>
+                                <literal>Default Scopes</literal>
+                            </entry>
+                            <entry>
+                                Allows you to manually specify the scopes that users must authorize when authenticating with this provider. 
+                                For a complete list of scopes, please take a look at application configuration in <ulink url="https://api.stackexchange.com/docs/authentication#scope">StackExchange API Authentication</ulink> documentation. Keycloak uses the empty scope by default.
+                            </entry>
+                        </row>
+                    </tbody>
+                </tgroup>
+            </table>
         </section>        
     </section>
 
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-facebook-ext.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-facebook-ext.html
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-facebook-ext.html
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-ext.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-ext.html
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-ext.html
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-google-ext.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-google-ext.html
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-google-ext.html
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-linkedin-ext.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-linkedin-ext.html
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-linkedin-ext.html
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html
index aa6a5eb..4a23349 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html
@@ -28,12 +28,13 @@
                         </div>
                         <span tooltip-placement="right" tooltip="The client or application secret registered withing the identity provider." class="fa fa-info-circle"></span>
                     </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-sm-2 control-label" for="defaultScope">Default Scopes </label>
                         <div class="col-sm-4">
                             <input class="form-control" id="defaultScope" type="text" ng-model="identityProvider.config.defaultScope">
                         </div>
-                        <span tooltip-placement="right" tooltip="The scopes to be sent when asking for authorization. It can be a space-separated list of scopes. Defaults to 'openid'." class="fa fa-info-circle"></span>
+                        <span tooltip-placement="right" tooltip="The scopes to be sent when asking for authorization. See documentation for possible values, separator and default value'." class="fa fa-info-circle"></span>
                     </div>
                     <div class="form-group">
                         <label class="col-sm-2 control-label" for="enabled">Store Tokens</label>
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-stackoverflow.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-stackoverflow.html
new file mode 100755
index 0000000..a4630ac
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-stackoverflow.html
@@ -0,0 +1 @@
+<div data-ng-include data-src="resourceUrl + '/partials/realm-identity-provider-social.html'"></div>
\ No newline at end of file
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-stackoverflow-ext.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-stackoverflow-ext.html
new file mode 100755
index 0000000..86516df
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-stackoverflow-ext.html
@@ -0,0 +1,7 @@
+                    <div class="form-group clearfix">
+                        <label class="col-sm-2 control-label" for="clientId">Key <span class="required">*</span></label>
+                        <div class="col-sm-4">
+                            <input class="form-control" id="clientId" type="text" ng-model="identityProvider.config.key" required>
+                        </div>
+                        <span tooltip-placement="right" tooltip="The Key obtained from Stack Overflow application registration." class="fa fa-info-circle"></span>
+                    </div>
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-twitter-ext.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-twitter-ext.html
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-twitter-ext.html
diff --git a/forms/common-themes/src/main/resources/theme/patternfly/login/resources/css/login.css b/forms/common-themes/src/main/resources/theme/patternfly/login/resources/css/login.css
index 345f594..2bc07bb 100644
--- a/forms/common-themes/src/main/resources/theme/patternfly/login/resources/css/login.css
+++ b/forms/common-themes/src/main/resources/theme/patternfly/login/resources/css/login.css
@@ -238,7 +238,7 @@ ol#kc-totp-settings li:first-of-type {
 }
 
 .zocial {
-    width: 125px;
+    width: 150px;
 }
 
 .zocial:hover {

social/pom.xml 1(+1 -0)

diff --git a/social/pom.xml b/social/pom.xml
index ded7c60..ec08ca2 100755
--- a/social/pom.xml
+++ b/social/pom.xml
@@ -21,6 +21,7 @@
         <module>twitter</module>
         <module>facebook</module>
         <module>linkedin</module>
+        <module>stackoverflow</module>
     </modules>
 
 </project>
diff --git a/social/stackoverflow/.gitignore b/social/stackoverflow/.gitignore
new file mode 100644
index 0000000..b83d222
--- /dev/null
+++ b/social/stackoverflow/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/social/stackoverflow/pom.xml b/social/stackoverflow/pom.xml
new file mode 100755
index 0000000..f05a77b
--- /dev/null
+++ b/social/stackoverflow/pom.xml
@@ -0,0 +1,40 @@
+<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/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>keycloak-social-parent</artifactId>
+        <groupId>org.keycloak</groupId>
+        <version>1.2.0.Beta1-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+    <packaging>jar</packaging>
+
+    <artifactId>keycloak-social-stackoverflow</artifactId>
+    <name>Keycloak Social StackOverflow</name>
+    <description/>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-social-core</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-broker-oidc</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.codehaus.jackson</groupId>
+            <artifactId>jackson-mapper-asl</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.jboss.logging</groupId>
+            <artifactId>jboss-logging</artifactId>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackoverflowIdentityProvider.java b/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackoverflowIdentityProvider.java
new file mode 100755
index 0000000..40fb32f
--- /dev/null
+++ b/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackoverflowIdentityProvider.java
@@ -0,0 +1,214 @@
+/*
+ * JBoss, Home of Professional Open Source
+ *
+ * Copyright 2015 Red Hat, Inc. and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.social.stackoverflow;
+
+import java.io.StringWriter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.HashMap;
+
+import org.codehaus.jackson.JsonNode;
+import org.jboss.logging.Logger;
+import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider;
+import org.keycloak.broker.oidc.util.SimpleHttp;
+import org.keycloak.broker.provider.FederatedIdentity;
+import org.keycloak.broker.provider.IdentityBrokerException;
+import org.keycloak.social.SocialIdentityProvider;
+
+/**
+ * Stackoverflow social provider. See https://api.stackexchange.com/docs/authentication
+ * 
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public class StackoverflowIdentityProvider extends AbstractOAuth2IdentityProvider<StackOverflowIdentityProviderConfig>
+		implements SocialIdentityProvider<StackOverflowIdentityProviderConfig> {
+
+	private static final Logger log = Logger.getLogger(StackoverflowIdentityProvider.class);
+
+	public static final String AUTH_URL = "https://stackexchange.com/oauth";
+	public static final String TOKEN_URL = "https://stackexchange.com/oauth/access_token";
+	public static final String PROFILE_URL = "https://api.stackexchange.com/2.2/me?order=desc&sort=name&site=stackoverflow";
+	public static final String DEFAULT_SCOPE = "";
+
+	public StackoverflowIdentityProvider(StackOverflowIdentityProviderConfig config) {
+		super(config);
+		config.setAuthorizationUrl(AUTH_URL);
+		config.setTokenUrl(TOKEN_URL);
+		config.setUserInfoUrl(PROFILE_URL);
+	}
+
+	@Override
+	protected FederatedIdentity doGetFederatedIdentity(String accessToken) {
+		log.debug("doGetFederatedIdentity()");
+		try {
+
+			String URL = PROFILE_URL + "&access_token=" + accessToken + "&key=" + getConfig().getKey();
+			if (log.isDebugEnabled()) {
+				log.debug("StackOverflow profile request to: " + URL);
+			}
+			JsonNode profile = SimpleHttp.doGet(URL).asJson().get("items").get(0);
+
+			FederatedIdentity user = new FederatedIdentity(getJsonProperty(profile, "user_id"));
+
+			user.setUsername(extractUsernameFromProfileURL(getJsonProperty(profile, "link")));
+			user.setName(unescapeHtml3(getJsonProperty(profile, "display_name")));
+			// email is not provided
+			// user.setEmail(getJsonProperty(profile, "email"));
+
+			return user;
+		} catch (Exception e) {
+			throw new IdentityBrokerException("Could not obtain user profile from Stackoverflow: " + e.getMessage(), e);
+		}
+	}
+
+	protected static String extractUsernameFromProfileURL(String profileURL) {
+		if (isNotBlank(profileURL)) {
+
+			try {
+				log.debug("go to extract username from profile URL " + profileURL);
+				URL u = new URL(profileURL);
+				String path = u.getPath();
+				if (isNotBlank(path) && path.length() > 1) {
+					if (path.startsWith("/")) {
+						path = path.substring(1);
+					}
+					String[] pe = path.split("/");
+					if (pe.length >= 3) {
+						return URLDecoder.decode(pe[2], "UTF-8");
+					} else {
+						log.warn("Stackoverflow profile URL path is without third part: " + profileURL);
+					}
+				} else {
+					log.warn("Stackoverflow profile URL is without path part: " + profileURL);
+				}
+			} catch (MalformedURLException e) {
+				log.warn("Stackoverflow profile URL is malformed: " + profileURL);
+			} catch (Exception e) {
+				log.warn("Stackoverflow profile URL " + profileURL + " username extraction failed due: " + e.getMessage());
+			}
+		}
+		return null;
+	}
+
+	private static boolean isNotBlank(String s) {
+		return s != null && s.trim().length() > 0;
+	}
+
+	@Override
+	protected String getDefaultScopes() {
+		return DEFAULT_SCOPE;
+	}
+
+	public static final String unescapeHtml3(final String input) {
+		if (input == null)
+			return null;
+		StringWriter writer = null;
+		int len = input.length();
+		int i = 1;
+		int st = 0;
+		while (true) {
+			// look for '&'
+			while (i < len && input.charAt(i - 1) != '&')
+				i++;
+			if (i >= len)
+				break;
+
+			// found '&', look for ';'
+			int j = i;
+			while (j < len && j < i + MAX_ESCAPE + 1 && input.charAt(j) != ';')
+				j++;
+			if (j == len || j < i + MIN_ESCAPE || j == i + MAX_ESCAPE + 1) {
+				i++;
+				continue;
+			}
+
+			// found escape
+			if (input.charAt(i) == '#') {
+				// numeric escape
+				int k = i + 1;
+				int radix = 10;
+
+				final char firstChar = input.charAt(k);
+				if (firstChar == 'x' || firstChar == 'X') {
+					k++;
+					radix = 16;
+				}
+
+				try {
+					int entityValue = Integer.parseInt(input.substring(k, j), radix);
+
+					if (writer == null)
+						writer = new StringWriter(input.length());
+					writer.append(input.substring(st, i - 1));
+
+					if (entityValue > 0xFFFF) {
+						final char[] chrs = Character.toChars(entityValue);
+						writer.write(chrs[0]);
+						writer.write(chrs[1]);
+					} else {
+						writer.write(entityValue);
+					}
+
+				} catch (NumberFormatException ex) {
+					i++;
+					continue;
+				}
+			} else {
+				// named escape
+				CharSequence value = lookupMap.get(input.substring(i, j));
+				if (value == null) {
+					i++;
+					continue;
+				}
+
+				if (writer == null)
+					writer = new StringWriter(input.length());
+				writer.append(input.substring(st, i - 1));
+
+				writer.append(value);
+			}
+
+			// skip escape
+			st = j + 1;
+			i = st;
+		}
+
+		if (writer != null) {
+			writer.append(input.substring(st, len));
+			return writer.toString();
+		}
+		return input;
+	}
+
+	private static final String[][] ESCAPES = { { "\"", "quot" }, // " - double-quote
+			{ "&", "amp" }, // & - ampersand
+			{ "<", "lt" }, // < - less-than
+			{ ">", "gt" }, // > - greater-than
+	};
+
+	private static final int MIN_ESCAPE = 2;
+	private static final int MAX_ESCAPE = 6;
+
+	private static final HashMap<String, CharSequence> lookupMap;
+	static {
+		lookupMap = new HashMap<String, CharSequence>();
+		for (final CharSequence[] seq : ESCAPES)
+			lookupMap.put(seq[1].toString(), seq[0]);
+	}
+}
diff --git a/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackOverflowIdentityProviderConfig.java b/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackOverflowIdentityProviderConfig.java
new file mode 100644
index 0000000..f531d7c
--- /dev/null
+++ b/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackOverflowIdentityProviderConfig.java
@@ -0,0 +1,40 @@
+/*
+ * JBoss, Home of Professional Open Source
+ *
+ * Copyright 2013 Red Hat, Inc. and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.social.stackoverflow;
+
+import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
+import org.keycloak.models.IdentityProviderModel;
+
+/**
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public class StackOverflowIdentityProviderConfig extends OAuth2IdentityProviderConfig {
+
+	public StackOverflowIdentityProviderConfig(IdentityProviderModel model) {
+		super(model);
+	}
+
+	public String getKey() {
+		return getConfig().get("key");
+	}
+
+	public void setKey(String key) {
+		getConfig().put("key", key);
+	}
+
+}
\ No newline at end of file
diff --git a/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackoverflowIdentityProviderFactory.java b/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackoverflowIdentityProviderFactory.java
new file mode 100644
index 0000000..d02c2d7
--- /dev/null
+++ b/social/stackoverflow/src/main/java/org/keycloak/social/stackoverflow/StackoverflowIdentityProviderFactory.java
@@ -0,0 +1,47 @@
+/*
+ * JBoss, Home of Professional Open Source
+ *
+ * Copyright 2015 Red Hat, Inc. and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.social.stackoverflow;
+
+import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
+import org.keycloak.models.IdentityProviderModel;
+import org.keycloak.social.SocialIdentityProviderFactory;
+
+/**
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public class StackoverflowIdentityProviderFactory extends
+		AbstractIdentityProviderFactory<StackoverflowIdentityProvider> implements
+		SocialIdentityProviderFactory<StackoverflowIdentityProvider> {
+
+	public static final String PROVIDER_ID = "stackoverflow";
+
+	@Override
+	public String getName() {
+		return "StackOverflow";
+	}
+
+	@Override
+	public StackoverflowIdentityProvider create(IdentityProviderModel model) {
+		return new StackoverflowIdentityProvider(new StackOverflowIdentityProviderConfig(model));
+	}
+
+	@Override
+	public String getId() {
+		return PROVIDER_ID;
+	}
+}
diff --git a/social/stackoverflow/src/main/resources/META-INF/services/org.keycloak.social.SocialIdentityProviderFactory b/social/stackoverflow/src/main/resources/META-INF/services/org.keycloak.social.SocialIdentityProviderFactory
new file mode 100644
index 0000000..4bbbe9c
--- /dev/null
+++ b/social/stackoverflow/src/main/resources/META-INF/services/org.keycloak.social.SocialIdentityProviderFactory
@@ -0,0 +1 @@
+org.keycloak.social.stackoverflow.StackoverflowIdentityProviderFactory
\ No newline at end of file
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java
index 84bbc9c..57886e7 100644
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java
@@ -25,6 +25,7 @@ import org.keycloak.social.github.GitHubIdentityProviderFactory;
 import org.keycloak.social.google.GoogleIdentityProviderFactory;
 import org.keycloak.social.twitter.TwitterIdentityProviderFactory;
 import org.keycloak.social.linkedin.LinkedInIdentityProviderFactory;
+import org.keycloak.social.stackoverflow.StackoverflowIdentityProviderFactory;
 import org.keycloak.testsuite.model.AbstractModelTest;
 
 import java.util.Collections;
@@ -49,6 +50,7 @@ public abstract class AbstractIdentityProviderModelTest extends AbstractModelTes
         this.expectedProviders.add(GitHubIdentityProviderFactory.PROVIDER_ID);
         this.expectedProviders.add(TwitterIdentityProviderFactory.PROVIDER_ID);
         this.expectedProviders.add(LinkedInIdentityProviderFactory.PROVIDER_ID);
+        this.expectedProviders.add(StackoverflowIdentityProviderFactory.PROVIDER_ID);
 
         this.expectedProviders = Collections.unmodifiableSet(this.expectedProviders);
     }
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
index f596f1b..eda8b69 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java
@@ -17,6 +17,11 @@
  */
 package org.keycloak.testsuite.broker;
 
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
 import org.junit.Test;
 import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
 import org.keycloak.broker.oidc.OIDCIdentityProvider;
@@ -36,15 +41,13 @@ import org.keycloak.social.github.GitHubIdentityProvider;
 import org.keycloak.social.github.GitHubIdentityProviderFactory;
 import org.keycloak.social.google.GoogleIdentityProvider;
 import org.keycloak.social.google.GoogleIdentityProviderFactory;
-import org.keycloak.social.twitter.TwitterIdentityProvider;
-import org.keycloak.social.twitter.TwitterIdentityProviderFactory;
 import org.keycloak.social.linkedin.LinkedInIdentityProvider;
 import org.keycloak.social.linkedin.LinkedInIdentityProviderFactory;
-
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import org.keycloak.social.stackoverflow.StackOverflowIdentityProviderConfig;
+import org.keycloak.social.stackoverflow.StackoverflowIdentityProvider;
+import org.keycloak.social.stackoverflow.StackoverflowIdentityProviderFactory;
+import org.keycloak.social.twitter.TwitterIdentityProvider;
+import org.keycloak.social.twitter.TwitterIdentityProviderFactory;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -164,6 +167,8 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
                     assertTwitterIdentityProviderConfig(identityProvider);
                 } else if (LinkedInIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
                     assertLinkedInIdentityProviderConfig(identityProvider);
+                } else if (StackoverflowIdentityProviderFactory.PROVIDER_ID.equals(providerId)) {
+                    assertStackoverflowIdentityProviderConfig(identityProvider);
                 } else {
                     continue;
                 }
@@ -262,8 +267,8 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
     }
 
     private void assertLinkedInIdentityProviderConfig(IdentityProviderModel identityProvider) {
-      LinkedInIdentityProvider gitHubIdentityProvider = new LinkedInIdentityProviderFactory().create(identityProvider);
-      OAuth2IdentityProviderConfig config = gitHubIdentityProvider.getConfig();
+        LinkedInIdentityProvider liIdentityProvider = new LinkedInIdentityProviderFactory().create(identityProvider);
+        OAuth2IdentityProviderConfig config = liIdentityProvider.getConfig();
 
         assertEquals("model-linkedin", config.getAlias());
       assertEquals(LinkedInIdentityProviderFactory.PROVIDER_ID, config.getProviderId());
@@ -278,6 +283,24 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes
       assertEquals(LinkedInIdentityProvider.PROFILE_URL, config.getUserInfoUrl());
     }
 
+    private void assertStackoverflowIdentityProviderConfig(IdentityProviderModel identityProvider) {
+        StackoverflowIdentityProvider soIdentityProvider = new StackoverflowIdentityProviderFactory().create(identityProvider);
+        StackOverflowIdentityProviderConfig config = soIdentityProvider.getConfig();
+
+        assertEquals("model-stackoverflow", config.getAlias());
+        assertEquals(StackoverflowIdentityProviderFactory.PROVIDER_ID, config.getProviderId());
+        assertEquals(true, config.isEnabled());
+        assertEquals(true, config.isUpdateProfileFirstLogin());
+        assertEquals(false, config.isAuthenticateByDefault());
+        assertEquals(false, config.isStoreToken());
+        assertEquals("clientId", config.getClientId());
+        assertEquals("clientSecret", config.getClientSecret());
+        assertEquals("keyValue", config.getKey());
+        assertEquals(StackoverflowIdentityProvider.AUTH_URL, config.getAuthorizationUrl());
+        assertEquals(StackoverflowIdentityProvider.TOKEN_URL, config.getTokenUrl());
+        assertEquals(StackoverflowIdentityProvider.PROFILE_URL, config.getUserInfoUrl());
+    }
+
     private void assertTwitterIdentityProviderConfig(IdentityProviderModel identityProvider) {
         TwitterIdentityProvider twitterIdentityProvider = new TwitterIdentityProviderFactory().create(identityProvider);
         OAuth2IdentityProviderConfig config = twitterIdentityProvider.getConfig();
diff --git a/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json b/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json
index 96cdc96..7174420 100755
--- a/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json
+++ b/testsuite/integration/src/test/resources/broker-test/test-realm-with-broker.json
@@ -76,6 +76,21 @@
             }
         },
         {
+            "alias" : "model-stackoverflow",
+            "providerId" : "stackoverflow",
+            "enabled": true,
+            "updateProfileFirstLogin" : "true",
+            "storeToken": false,
+            "config": {
+                "key": "keyValue",
+                "authorizationUrl": "authorizationUrl",
+                "tokenUrl": "tokenUrl",
+                "userInfoUrl": "userInfoUrl",
+                "clientId": "clientId",
+                "clientSecret": "clientSecret"
+            }
+        },
+        {
           "alias" : "model-saml-signed-idp",
           "providerId" : "saml",
           "enabled": true,