001package com.nimbusds.openid.connect.provider.spi.claims.ldap;
002
003
004import java.io.InputStream;
005import java.nio.charset.Charset;
006import java.util.*;
007
008import org.apache.commons.io.IOUtils;
009
010import org.apache.logging.log4j.LogManager;
011import org.apache.logging.log4j.Logger;
012
013import net.minidev.json.JSONObject;
014
015import com.unboundid.ldap.sdk.Entry;
016import com.unboundid.ldap.sdk.LDAPConnectionPool;
017import com.unboundid.ldap.sdk.SearchResult;
018
019import com.nimbusds.common.ldap.AttributeMapper;
020import com.nimbusds.common.ldap.LDAPConnectionPoolFactory;
021
022import com.nimbusds.langtag.LangTag;
023
024import com.nimbusds.oauth2.sdk.id.Subject;
025import com.nimbusds.oauth2.sdk.util.JSONObjectUtils;
026import com.nimbusds.openid.connect.sdk.claims.UserInfo;
027
028import com.nimbusds.openid.connect.provider.spi.InitContext;
029import com.nimbusds.openid.connect.provider.spi.claims.ClaimUtils;
030import com.nimbusds.openid.connect.provider.spi.claims.ClaimsSource;
031
032
033/**
034 * LDAP connector for retrieving OpenID Connect UserInfo claims.
035 */
036public class LDAPClaimsSource implements ClaimsSource {
037
038
039        /**
040         * The configuration file path.
041         */
042        public static final String CONFIG_FILE_PATH = "/WEB-INF/ldapClaimsSource.properties";
043
044
045        /**
046         * The LDAP claims map file path.
047         */
048        public static final String MAP_FILE_PATH = "/WEB-INF/ldapClaimsMap.json";
049
050
051        /**
052         * The LDAP connector configuration.
053         */
054        private Configuration config;
055
056
057        /**
058         * The supported UserInfo claims map. Allows for mapping of complex top
059         * level claims, such as "address" to their sub-claims, while simple
060         * claims map one-to-one.
061         */
062        private Map<String,List<String>> claimsMap;
063
064
065        /**
066         * The UserInfo attribute mapper.
067         */
068        private AttributeMapper attributeMapper;
069
070
071        /**
072         * The LDAP connection pool.
073         */
074        private LDAPConnectionPool ldapConnPool;
075
076
077        /**
078         * The logger.
079         */
080        private final Logger log = LogManager.getLogger("MAIN");
081
082
083        /**
084         * Creates a new LDAP claims source. It must be {@link #init
085         * initialised} before it can be used.
086         */
087        public LDAPClaimsSource() { }
088
089
090        /**
091         * Loads the configuration.
092         *
093         * @param initContext The initialisation context. Must not be
094         *                    {@code null}.
095         *
096         * @return The configuration.
097         *
098         * @throws Exception If loading failed.
099         */
100        private static Configuration loadConfiguration(final InitContext initContext)
101                throws Exception {
102
103                InputStream inputStream = initContext.getResourceAsStream(CONFIG_FILE_PATH);
104
105                if (inputStream == null) {
106                        throw new Exception("Couldn't find LDAP claims source configuration file: " + CONFIG_FILE_PATH);
107                }
108
109                Properties props = new Properties();
110                props.load(inputStream);
111
112                return new Configuration(props);
113        }
114
115
116        /**
117         * Loads the LDAP attribute map.
118         *
119         * @param initContext The initialisation context. Must not be
120         *                    {@code null}.
121         *
122         * @return The LDAP attribute map.
123         *
124         * @throws Exception If loading failed.
125         */
126        private static Map<String,Object> loadLDAPAttributeMap(final InitContext initContext)
127                throws Exception {
128
129                InputStream inputStream = initContext.getResourceAsStream(MAP_FILE_PATH);
130
131                if (inputStream == null) {
132                        throw new Exception("Couldn't find LDAP claims map file: " + MAP_FILE_PATH);
133                }
134
135                try {
136                        String jsonText = IOUtils.toString(inputStream, Charset.forName("UTF-8"));
137                        return JSONObjectUtils.parseJSONObject(jsonText);
138
139                } catch (Exception e) {
140
141                        throw new Exception("Couldn't load LDAP claims map: " + e.getMessage(), e);
142                }
143        }
144
145
146        /**
147         * Composes the claims map from the specified LDAP attributes map.
148         *
149         * @param attrMap The LDAP attribute map. Must not be {@code null}.
150         *
151         * @return The claims map.
152         */
153        private static Map<String,List<String>> composeClaimsMap(final Map<String,Object> attrMap) {
154
155                // Derive the supported UserInfo claims map
156                Map<String,List<String>> claimsMap = new HashMap<>();
157
158                for (String key: attrMap.keySet()) {
159
160                        String[] parts = key.split("\\.", 2);
161
162                        List<String> subClaims = claimsMap.get(parts[0]);
163
164                        if (subClaims == null) {
165                                subClaims = new LinkedList<>();
166                        }
167
168                        subClaims.add(key);
169
170                        claimsMap.put(parts[0], subClaims);
171                }
172
173                return claimsMap;
174        }
175
176
177        @Override
178        public void init(final InitContext initContext)
179                throws Exception {
180
181                log.info("Initializing LDAP claims source...");
182
183                config = loadConfiguration(initContext);
184
185                config.log();
186
187                if (! config.enable) {
188                        // stop initialisation
189                        return;
190                }
191
192                // Load the raw LDAP attribute map
193                Map<String,Object> ldapAttributeMap = loadLDAPAttributeMap(initContext);
194                attributeMapper = new AttributeMapper(ldapAttributeMap);
195
196                if (attributeMapper.getLDAPAttributeName("sub") == null) {
197                        throw new Exception("Missing LDAP attribute mapping for \"sub\" claim");
198                }
199
200                // Compose the final claims map
201                claimsMap = composeClaimsMap(ldapAttributeMap);
202
203                LDAPConnectionPoolFactory factory = new LDAPConnectionPoolFactory(config.server,
204                        config.customTrustStore,
205                        config.customKeyStore,
206                        config.directory.user);
207
208                try {
209                        ldapConnPool = factory.createLDAPConnectionPool();
210
211                } catch (Exception e) {
212
213                        // java.security.KeyStoreException
214                        // java.security.GeneralSecurityException
215                        // com.unboundid.ldap.sdk.LDAPException
216
217                        throw new Exception("Couldn't create LDAP connection pool: " + e.getMessage(), e);
218                }
219
220                ldapConnPool.setConnectionPoolName("userinfo-store");
221        }
222
223
224        @Override
225        public boolean isEnabled() {
226
227                return config.enable;
228        }
229
230
231        @Override
232        public Set<String> supportedClaims() {
233
234                if (! config.enable) {
235                        // Empty set
236                        return Collections.unmodifiableSet(new HashSet<String>());
237                }
238
239                return Collections.unmodifiableSet(claimsMap.keySet());
240        }
241
242
243        /**
244         * Resolves the individual requested claims from the specified
245         * requested claims and preferred locales.
246         *
247         * @param claims        The requested claims. May contain optional
248         *                      language tags. Must not be {@code null}.
249         * @param claimsLocales The preferred locales, {@code null} if not
250         *                      specified.
251         *
252         * @return The resolved individual requested claims.
253         */
254        protected List<String> resolveRequestedClaims(final Set<String> claims,
255                                                      final List<LangTag> claimsLocales) {
256
257                // Use set to ensure no duplicates get into the collection
258                Set<String> individualClaims = new HashSet<>();
259
260                for (String claim: claims) {
261
262                        // Check if the claim is supported and if any sub-claims
263                        // are associated with it (e.g. for UserInfo address)
264                        List<String> claimsList = claimsMap.get(claim);
265
266                        if (claimsList == null) {
267                                // claim not supported
268                                continue;
269                        }
270
271                        individualClaims.addAll(claimsList);
272                }
273
274                // Apply the preferred language tags if any
275                individualClaims = ClaimUtils.applyLangTags(individualClaims, claimsLocales);
276
277                return new ArrayList<>(individualClaims);
278        }
279
280
281        @Override
282        public UserInfo getClaims(final Subject subject,
283                                  final Set<String> claims,
284                                  final List<LangTag> claimsLocales)
285                throws Exception {
286
287                if (! config.enable)
288                        return null;
289
290                // Compose search filter from the cofigured template
291                String filter = config.directory.filter.apply(subject.getValue());
292
293                // Resolve the individual requested claims
294                List<String> claimsToRequest = resolveRequestedClaims(claims, claimsLocales);
295
296                // Map OIDC claim names to LDAP attribute names
297                List<String> ldapAttrs = attributeMapper.getLDAPAttributeNames(claimsToRequest);
298
299                // Do LDAP search
300                SearchResult searchResult;
301
302                try {
303                        searchResult = ldapConnPool.search(
304                                config.directory.baseDN.toString(),
305                                config.directory.scope,
306                                filter,
307                                ldapAttrs.toArray(new String[0]));
308
309                } catch (Exception e) {
310
311                        // LDAPException
312                        throw new Exception("Couldn't get UserInfo for subject \"" + subject + "\": " + e.getMessage(), e);
313                }
314
315
316                // Get matches count
317                final int entryCount = searchResult.getEntryCount();
318
319                if (entryCount == 0) {
320                        // Nothing found
321                        return null;
322                }
323
324                if (entryCount > 1) {
325                        // More than one entry found
326                        throw new Exception("Found " + entryCount + " entries for subject \"" + subject + "\"");
327                }
328
329
330                // Process user entry
331                Entry entry = searchResult.getSearchEntries().get(0);
332
333
334                Map<String,Object> entryObject = attributeMapper.transform(entry);
335
336
337                // Remove unrequested attributes that have got into the entry
338                // See issue #2
339                List<String> unwantedClaims = new ArrayList<>();
340
341                for (String claimName: entryObject.keySet()) {
342
343                        if (! claims.contains(claimName))
344                                unwantedClaims.add(claimName);
345                }
346
347                for (String claimToRemove: unwantedClaims) {
348                        entryObject.remove(claimToRemove);
349                }
350
351                // Append mandatory "sub" claim
352                entryObject.put("sub", subject.getValue());
353
354                try {
355                        return new UserInfo(new JSONObject(entryObject));
356
357                } catch (IllegalArgumentException e) {
358
359                        throw new Exception("Couldn't create UserInfo object: " + e.getMessage(), e);
360                }
361        }
362
363
364        @Override
365        public void shutdown()
366                throws Exception {
367
368                if (ldapConnPool != null) {
369                        // Close the LDAP connection pool
370                        ldapConnPool.close();
371                }
372        }
373}