001/*
002 * oauth2-oidc-sdk
003 *
004 * Copyright 2012-2020, Connect2id Ltd and contributors.
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use
007 * this file except in compliance with the License. You may obtain a copy of the
008 * License at
009 *
010 *    http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software distributed
013 * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
014 * CONDITIONS OF ANY KIND, either express or implied. See the License for the
015 * specific language governing permissions and limitations under the License.
016 */
017
018package com.nimbusds.openid.connect.sdk.claims;
019
020
021import java.util.*;
022
023import net.jcip.annotations.Immutable;
024import net.minidev.json.JSONAware;
025import net.minidev.json.JSONObject;
026
027import com.nimbusds.langtag.LangTag;
028import com.nimbusds.langtag.LangTagException;
029import com.nimbusds.oauth2.sdk.ParseException;
030import com.nimbusds.oauth2.sdk.util.JSONObjectUtils;
031
032
033/**
034 * OpenID Connect claims set request, intended to represent the
035 * {@code userinfo} and {@code id_token} elements in a
036 * {@link com.nimbusds.openid.connect.sdk.OIDCClaimsRequest claims} request
037 * parameter.
038 *
039 * <p>Example:
040 *
041 * <pre>
042 * {
043 *   "given_name": {"essential": true},
044 *   "nickname": null,
045 *   "email": {"essential": true},
046 *   "email_verified": {"essential": true},
047 *   "picture": null,
048 *   "http://example.info/claims/groups": null
049 * }
050 * </pre>
051 *
052 * <p>Related specifications:
053 *
054 * <ul>
055 *     <li>OpenID Connect Core 1.0, section 5.5.
056 *     <li>OpenID Connect for Identity Assurance 1.0.
057 * </ul>
058 */
059@Immutable
060public class ClaimsSetRequest implements JSONAware {
061        
062        
063        /**
064         * Individual OpenID claim request.
065         *
066         * <p>Related specifications:
067         *
068         * <ul>
069         *     <li>OpenID Connect Core 1.0, section 5.5.1.
070         *     <li>OpenID Connect for Identity Assurance 1.0.
071         * </ul>
072         */
073        @Immutable
074        public static class Entry {
075                
076                
077                /**
078                 * The claim name.
079                 */
080                private final String claimName;
081                
082                
083                /**
084                 * The claim requirement.
085                 */
086                private final ClaimRequirement requirement;
087                
088                
089                /**
090                 * Optional language tag.
091                 */
092                private final LangTag langTag;
093                
094                
095                /**
096                 * Optional claim value.
097                 */
098                private final String value;
099                
100                
101                /**
102                 * Optional claim values.
103                 */
104                private final List<String> values;
105                
106                
107                /**
108                 * Optional claim purpose.
109                 */
110                private final String purpose;
111                
112                
113                /**
114                 * Optional additional claim information.
115                 *
116                 * <p>Example additional information in the "info" member:
117                 *
118                 * <pre>
119                 * {
120                 *   "userinfo" : {
121                 *       "email": null,
122                 *       "email_verified": null,
123                 *       "http://example.info/claims/groups" : { "info" : "custom information" }
124                 *   }
125                 * }
126                 * </pre>
127                 */
128                private final Map<String, Object> additionalInformation;
129                
130                
131                /**
132                 * Creates a new individual claim request. The claim
133                 * requirement is set to {@link ClaimRequirement#VOLUNTARY
134                 * voluntary} (the default) and no expected value(s) or other
135                 * parameters are specified.
136                 *
137                 * @param claimName The claim name. Must not be {@code null}.
138                 */
139                public Entry(final String claimName) {
140                        this(claimName, ClaimRequirement.VOLUNTARY, null, null, null, null, null);
141                }
142                
143                
144                /**
145                 * Creates a new individual claim request. This constructor is
146                 * to be used privately. Ensures that {@code value} and
147                 * {@code values} are not simultaneously specified.
148                 *
149                 * @param claimName             The claim name. Must not be
150                 *                              {@code null}.
151                 * @param requirement           The claim requirement. Must not
152                 *                              be {@code null}.
153                 * @param langTag               Optional language tag for the
154                 *                              claim.
155                 * @param value                 Optional expected value for the
156                 *                              claim. If set, then the {@code
157                 *                              values} parameter must not be
158                 *                              set.
159                 * @param values                Optional expected values for
160                 *                              the claim. If set, then the
161                 *                              {@code value} parameter must
162                 *                              not be set.
163                 * @param purpose               The purpose for the requested
164                 *                              claim, {@code null} if not
165                 *                              specified.
166                 * @param additionalInformation Optional additional information
167                 */
168                private Entry(final String claimName,
169                              final ClaimRequirement requirement,
170                              final LangTag langTag,
171                              final String value,
172                              final List<String> values,
173                              final String purpose,
174                              final Map<String, Object> additionalInformation) {
175                        
176                        if (claimName == null)
177                                throw new IllegalArgumentException("The claim name must not be null");
178                        
179                        this.claimName = claimName;
180                        
181                        
182                        if (requirement == null)
183                                throw new IllegalArgumentException("The claim requirement must not be null");
184                        
185                        this.requirement = requirement;
186                        
187                        
188                        this.langTag = langTag;
189                        
190                        
191                        if (value != null && values == null) {
192                                
193                                this.value = value;
194                                this.values = null;
195                                
196                        } else if (value == null && values != null) {
197                                
198                                this.value = null;
199                                this.values = values;
200                                
201                        } else if (value == null && values == null) {
202                                
203                                this.value = null;
204                                this.values = null;
205                                
206                        } else {
207                                
208                                throw new IllegalArgumentException("Either value or values must be specified, but not both");
209                        }
210                        
211                        this.purpose = purpose;
212                        
213                        this.additionalInformation = additionalInformation;
214                }
215                
216                
217                /**
218                 * Returns the claim name.
219                 *
220                 * @return The claim name.
221                 */
222                public String getClaimName() {
223                        return getClaimName(false);
224                }
225                
226                
227                /**
228                 * Returns the claim name, optionally with the language tag
229                 * appended.
230                 *
231                 * <p>Example with language tag:
232                 *
233                 * <pre>
234                 * name#de-DE
235                 * </pre>
236                 *
237                 * @param withLangTag If {@code true} the language tag will be
238                 *                    appended to the name (if any), else not.
239                 *
240                 * @return The claim name, with optionally appended language
241                 *         tag.
242                 */
243                public String getClaimName(final boolean withLangTag) {
244                        
245                        if (withLangTag && langTag != null)
246                                return claimName + "#" + langTag.toString();
247                        else
248                                return claimName;
249                }
250                
251                
252                /**
253                 * Sets the claim requirement.
254                 *
255                 * @param requirement The claim requirement. Must not be
256                 *                    {@code null},
257                 *
258                 * @return The updated entry.
259                 */
260                public ClaimsSetRequest.Entry withClaimRequirement(final ClaimRequirement requirement) {
261                        return new ClaimsSetRequest.Entry(claimName, requirement, langTag, value, values, purpose, additionalInformation);
262                }
263                
264                
265                /**
266                 * Returns the claim requirement.
267                 *
268                 * @return The claim requirement.
269                 */
270                public ClaimRequirement getClaimRequirement() {
271                        return requirement;
272                }
273                
274                
275                /**
276                 * Sets the language tag for the claim.
277                 *
278                 * @param langTag The language tag, {@code null} if not
279                 *                specified.
280                 *
281                 * @return The updated entry.
282                 */
283                public ClaimsSetRequest.Entry withLangTag(final LangTag langTag) {
284                        return new ClaimsSetRequest.Entry(claimName, requirement, langTag, value, values, purpose, additionalInformation);
285                }
286                
287                
288                /**
289                 * Returns the optional language tag for the claim.
290                 *
291                 * @return The language tag, {@code null} if not specified.
292                 */
293                public LangTag getLangTag() {
294                        return langTag;
295                }
296                
297                
298                /**
299                 * Sets the requested value for the claim.
300                 *
301                 * @param value The value, {@code null} if not specified.
302                 *
303                 * @return The updated entry.
304                 */
305                public ClaimsSetRequest.Entry withValue(final String value) {
306                        return new ClaimsSetRequest.Entry(claimName, requirement, langTag, value, null, purpose, additionalInformation);
307                }
308                
309                
310                /**
311                 * Returns the requested value for the claim.
312                 *
313                 * @return The value, {@code null} if not specified.
314                 */
315                public String getValue() {
316                        return value;
317                }
318                
319                
320                /**
321                 * Sets the requested values for the claim.
322                 *
323                 * @param values The values, {@code null} if not specified.
324                 *
325                 * @return The updated entry.
326                 */
327                public ClaimsSetRequest.Entry withValues(final List<String> values) {
328                        return new ClaimsSetRequest.Entry(claimName, requirement, langTag, null, values, purpose, additionalInformation);
329                }
330                
331                
332                /**
333                 * Returns the requested values for the claim.
334                 *
335                 * @return The values, {@code null} if not specified.
336                 */
337                public List<String> getValues() {
338                        return values;
339                }
340                
341                
342                /**
343                 * Sets the purpose for which the claim is requested.
344                 *
345                 * @param purpose The purpose, {@code null} if not specified.
346                 *
347                 * @return The updated entry.
348                 */
349                public ClaimsSetRequest.Entry withPurpose(final String purpose) {
350                        return new ClaimsSetRequest.Entry(claimName, requirement, langTag, value, values, purpose, additionalInformation);
351                }
352                
353                
354                /**
355                 * Returns the optional purpose for which the claim is
356                 * requested.
357                 *
358                 * @return The purpose, {@code null} if not specified.
359                 */
360                public String getPurpose() {
361                        return purpose;
362                }
363                
364                
365                /**
366                 * Sets additional information for the requested claim.
367                 *
368                 * <p>Example additional information in the "info" member:
369                 *
370                 * <pre>
371                 * {
372                 *   "userinfo" : {
373                 *       "email": null,
374                 *       "email_verified": null,
375                 *       "http://example.info/claims/groups" : { "info" : "custom information" }
376                 *   }
377                 * }
378                 * </pre>
379                 *
380                 * @param additionalInformation The additional information,
381                 *                              {@code null} if not specified.
382                 *
383                 * @return The updated entry.
384                 */
385                public ClaimsSetRequest.Entry withAdditionalInformation(final Map<String, Object> additionalInformation) {
386                        return new ClaimsSetRequest.Entry(claimName, requirement, langTag, value, values, purpose, additionalInformation);
387                }
388                
389                
390                /**
391                 * Returns the additional information for the claim.
392                 *
393                 * <p>Example additional information in the "info" member:
394                 *
395                 * <pre>
396                 * {
397                 *   "userinfo" : {
398                 *       "email": null,
399                 *       "email_verified": null,
400                 *       "http://example.info/claims/groups" : { "info" : "custom information" }
401                 *   }
402                 * }
403                 * </pre>
404                 *
405                 * @return The additional information, {@code null} if not
406                 *         specified.
407                 */
408                public Map<String, Object> getAdditionalInformation() {
409                        return additionalInformation;
410                }
411                
412                
413                /**
414                 * Returns the JSON object entry for this individual claim
415                 * request.
416                 *
417                 * @return The JSON object entry.
418                 */
419                public Map.Entry<String,JSONObject> toJSONObjectEntry() {
420                        
421                        // Compose the optional value
422                        JSONObject entrySpec = null;
423                        
424                        if (getValue() != null) {
425                                
426                                entrySpec = new JSONObject();
427                                entrySpec.put("value", getValue());
428                        }
429                        
430                        if (getValues() != null) {
431                                
432                                // Either "value" or "values", or none
433                                // may be defined
434                                entrySpec = new JSONObject();
435                                entrySpec.put("values", getValues());
436                        }
437                        
438                        if (getClaimRequirement().equals(ClaimRequirement.ESSENTIAL)) {
439                                
440                                if (entrySpec == null)
441                                        entrySpec = new JSONObject();
442                                
443                                entrySpec.put("essential", true);
444                        }
445                        
446                        if (getPurpose() != null) {
447                                if (entrySpec == null) {
448                                        entrySpec = new JSONObject();
449                                }
450                                entrySpec.put("purpose", getPurpose());
451                        }
452                        
453                        if (getAdditionalInformation() != null) {
454                                if (entrySpec == null) {
455                                        entrySpec = new JSONObject();
456                                }
457                                for (Map.Entry<String, Object> additionalInformationEntry : getAdditionalInformation().entrySet()) {
458                                        entrySpec.put(additionalInformationEntry.getKey(), additionalInformationEntry.getValue());
459                                }
460                        }
461                        
462                        return new AbstractMap.SimpleImmutableEntry<>(getClaimName(true), entrySpec);
463                }
464                
465                
466                /**
467                 * Parses an individual claim request from the specified JSON
468                 * object entry.
469                 *
470                 * @param jsonObjectEntry The JSON object entry to parse. Must
471                 *                        not be {@code null}.
472                 *
473                 * @return The individual claim request.
474                 *
475                 * @throws ParseException If parsing failed.
476                 */
477                public static ClaimsSetRequest.Entry parse(final Map.Entry<String,JSONObject> jsonObjectEntry)
478                        throws ParseException {
479                        
480                        // Process the key
481                        String claimNameWithOptLangTag = jsonObjectEntry.getKey();
482                        
483                        String claimName;
484                        LangTag langTag = null;
485                        
486                        if (claimNameWithOptLangTag.contains("#")) {
487                                
488                                String[] parts = claimNameWithOptLangTag.split("#", 2);
489                                
490                                claimName = parts[0];
491                                
492                                try {
493                                        langTag = LangTag.parse(parts[1]);
494                                } catch (LangTagException e) {
495                                        throw new ParseException(e.getMessage(), e);
496                                }
497                                
498                        } else {
499                                claimName = claimNameWithOptLangTag;
500                        }
501                        
502                        // Parse the optional spec
503                        
504                        JSONObject spec = jsonObjectEntry.getValue();
505                        
506                        if (spec == null) {
507                                // Voluntary claim with no value(s)
508                                return new ClaimsSetRequest.Entry(claimName).withLangTag(langTag);
509                        }
510                        
511                        ClaimRequirement requirement = ClaimRequirement.VOLUNTARY;
512                        
513                        if (spec.containsKey("essential")) {
514                                
515                                boolean isEssential = JSONObjectUtils.getBoolean(spec, "essential");
516                                
517                                if (isEssential)
518                                        requirement = ClaimRequirement.ESSENTIAL;
519                        }
520                        
521                        String purpose = JSONObjectUtils.getString(spec, "purpose", null);
522                        
523                        if (spec.containsKey("value")) {
524                                
525                                String expectedValue = JSONObjectUtils.getString(spec, "value", null);
526                                Map<String, Object> additionalInformation = getAdditionalInformationFromClaim(spec);
527                                return new ClaimsSetRequest.Entry(claimName, requirement, langTag, expectedValue, null, purpose, additionalInformation);
528                                
529                        } else if (spec.containsKey("values")) {
530                                
531                                List<String> expectedValues = JSONObjectUtils.getStringList(spec, "values", null);
532                                Map<String, Object> additionalInformation = getAdditionalInformationFromClaim(spec);
533                                return new ClaimsSetRequest.Entry(claimName, requirement, langTag, null, expectedValues, purpose, additionalInformation);
534                                
535                        } else {
536                                Map<String, Object> additionalInformation = getAdditionalInformationFromClaim(spec);
537                                return new ClaimsSetRequest.Entry(claimName, requirement, langTag, null, null, purpose, additionalInformation);
538                        }
539                }
540                
541                
542                private static Map<String, Object> getAdditionalInformationFromClaim(final JSONObject spec) {
543                        
544                        Set<String> stdKeys = new HashSet<>(Arrays.asList("essential", "value", "values", "purpose"));
545                        
546                        Map<String, Object> additionalClaimInformation = new HashMap<>();
547                        
548                        for (Map.Entry<String, Object> additionalClaimInformationEntry : spec.entrySet()) {
549                                if (stdKeys.contains(additionalClaimInformationEntry.getKey())) {
550                                        continue; // skip std key
551                                }
552                                additionalClaimInformation.put(additionalClaimInformationEntry.getKey(), additionalClaimInformationEntry.getValue());
553                        }
554                        
555                        return additionalClaimInformation.isEmpty() ? null : additionalClaimInformation;
556                }
557        }
558        
559        
560        /**
561         * The request entries.
562         */
563        private final Collection<ClaimsSetRequest.Entry> entries;
564        
565        
566        /**
567         * Creates a new empty OpenID Connect claims set request.
568         */
569        public ClaimsSetRequest() {
570                this(Collections.<Entry>emptyList());
571        }
572        
573        
574        /**
575         * Creates a new OpenID Connect claims set request.
576         *
577         * @param entries The request entries, empty collection if none. Must
578         *                not be {@code null}.
579         */
580        public ClaimsSetRequest(final Collection<ClaimsSetRequest.Entry> entries) {
581                if (entries == null) {
582                        throw new IllegalArgumentException("The entries must not be null");
583                }
584                this.entries = Collections.unmodifiableCollection(entries);
585        }
586        
587        
588        /**
589         * Adds the specified claim to the request, using default settings.
590         * Shorthand for {@link #add(Entry)}.
591         *
592         * @param claimName The claim name. Must not be {@code null}.
593         *
594         * @return The updated claims set request.
595         */
596        public ClaimsSetRequest add(final String claimName) {
597                return add(new ClaimsSetRequest.Entry(claimName));
598        }
599        
600        
601        /**
602         * Adds the specified claim to the request.
603         *
604         * @param entry The individual claim request. Must not be {@code null}.
605         *
606         * @return The updated claims set request.
607         */
608        public ClaimsSetRequest add(final ClaimsSetRequest.Entry entry) {
609                List<Entry> updatedEntries = new LinkedList<>(getEntries());
610                updatedEntries.add(entry);
611                return new ClaimsSetRequest(updatedEntries);
612        }
613        
614        
615        /**
616         * Gets the request entries.
617         *
618         * @return The request entries, empty collection if none.
619         */
620        public Collection<ClaimsSetRequest.Entry> getEntries() {
621                return Collections.unmodifiableCollection(entries);
622        }
623        
624        
625        /**
626         * Gets the names of the requested claims.
627         *
628         * @param withLangTag If {@code true} the language tags, if any, will
629         *                    be appended to the names, else not.
630         *
631         * @return The claim names, as an unmodifiable set, empty set if none.
632         */
633        public Set<String> getClaimNames(final boolean withLangTag) {
634                Set<String> names = new HashSet<>();
635                for (ClaimsSetRequest.Entry en : entries) {
636                        names.add(en.getClaimName(withLangTag));
637                }
638                return Collections.unmodifiableSet(names);
639        }
640        
641        
642        /**
643         * Gets the specified claim entry from this request.
644         *
645         * @param claimName The claim name. Must not be {@code null}.
646         * @param langTag   The associated language tag, {@code null} if none.
647         *
648         * @return The claim entry, {@code null} if not found.
649         */
650        public Entry get(final String claimName, final LangTag langTag) {
651                
652                for (ClaimsSetRequest.Entry en: getEntries()) {
653                        if (claimName.equals(en.getClaimName()) && langTag == null && en.getLangTag() == null) {
654                                // No lang tag
655                                return en;
656                        } else if (claimName.equals(en.getClaimName()) && langTag != null && langTag.equals(en.getLangTag())) {
657                                // Matching lang tag
658                                return en;
659                        }
660                }
661                return null;
662        }
663        
664        
665        /**
666         * Deletes the specified claim from this request.
667         *
668         * @param claimName The claim name. Must not be {@code null}.
669         * @param langTag   The associated language tag, {@code null} if none.
670         *
671         * @return The updated claims set request.
672         */
673        public ClaimsSetRequest delete(final String claimName, final LangTag langTag) {
674                
675                Collection<ClaimsSetRequest.Entry> updatedEntries = new LinkedList<>();
676                
677                for (ClaimsSetRequest.Entry en: getEntries()) {
678                        if (claimName.equals(en.getClaimName()) && langTag == null && en.getLangTag() == null) {
679                                // don't copy
680                        } else if (claimName.equals(en.getClaimName()) && langTag != null && langTag.equals(en.getLangTag())) {
681                                // don't copy
682                        } else {
683                                updatedEntries.add(en);
684                        }
685                }
686                
687                return new ClaimsSetRequest(updatedEntries);
688        }
689        
690        
691        /**
692         * Deletes the specified claim from this request, in all existing
693         * language tag variations if any.
694         *
695         * @param claimName The claim name. Must not be {@code null}.
696         *
697         * @return The updated claims set request.
698         */
699        public ClaimsSetRequest delete(final String claimName) {
700                Collection<ClaimsSetRequest.Entry> updatedEntries = new LinkedList<>();
701                
702                for (ClaimsSetRequest.Entry en: getEntries()) {
703                        if (claimName.equals(en.getClaimName())) {
704                                // don't copy
705                        } else {
706                                updatedEntries.add(en);
707                        }
708                }
709                
710                return new ClaimsSetRequest(updatedEntries);
711        }
712        
713        
714        /**
715         * Returns the JSON object representation of this claims set request.
716         *
717         * <p>Example:
718         *
719         * <pre>
720         * {
721         *   "given_name": {"essential": true},
722         *   "nickname": null,
723         *   "email": {"essential": true},
724         *   "email_verified": {"essential": true},
725         *   "picture": null,
726         *   "http://example.info/claims/groups": null
727         * }
728         * </pre>
729         *
730         * @return The JSON object, empty if no claims are specified.
731         */
732        public JSONObject toJSONObject() {
733                JSONObject o = new JSONObject();
734                for (ClaimsSetRequest.Entry entry : entries) {
735                        Map.Entry<String, JSONObject> jsonObjectEntry = entry.toJSONObjectEntry();
736                        o.put(jsonObjectEntry.getKey(), jsonObjectEntry.getValue());
737                }
738                return o;
739        }
740        
741        
742        @Override
743        public String toJSONString() {
744                return toJSONObject().toJSONString();
745        }
746        
747        
748        @Override
749        public String toString() {
750                return toJSONString();
751        }
752        
753        
754        /**
755         * Parses an OpenID Connect claims set request from the specified JSON
756         * object representation.
757         *
758         * @param jsonObject The JSON object to parse. Must not be
759         *                   {@code null}.
760         *
761         * @return The claims set request.
762         *
763         * @throws ParseException If parsing failed.
764         */
765        public static ClaimsSetRequest parse(final JSONObject jsonObject)
766                throws ParseException {
767                
768                ClaimsSetRequest claimsRequest = new ClaimsSetRequest();
769
770                for (String key: jsonObject.keySet()) {
771                        
772                        if ("verified_claims".equals(key)) {
773                                // Implies nested VerifiedClaimsSetRequest, skip
774                                continue;
775                        }
776                        
777                        JSONObject value = JSONObjectUtils.getJSONObject(jsonObject, key, null);
778                        
779                        claimsRequest = claimsRequest.add(ClaimsSetRequest.Entry.parse(new AbstractMap.SimpleImmutableEntry<>(key, value)));
780                }
781                
782                return claimsRequest;
783        }
784        
785        
786        /**
787         * Parses an OpenID Connect claims set request from the specified JSON
788         * object string representation.
789         *
790         * @param json The JSON object string to parse. Must not be
791         *             {@code null}.
792         *
793         * @return The claims set request.
794         *
795         * @throws ParseException If parsing failed.
796         */
797        public static ClaimsSetRequest parse(final String json)
798                throws ParseException {
799                
800                return parse(JSONObjectUtils.parse(json));
801        }
802}