001/*
002 * oauth2-oidc-sdk
003 *
004 * Copyright 2012-2021, 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.oauth2.sdk.dpop.verifiers;
019
020
021import java.nio.charset.StandardCharsets;
022import java.security.MessageDigest;
023import java.security.NoSuchAlgorithmException;
024import java.util.Date;
025import java.util.Map;
026import java.util.Timer;
027import java.util.TimerTask;
028import java.util.concurrent.ConcurrentHashMap;
029
030import net.jcip.annotations.ThreadSafe;
031
032import com.nimbusds.jose.util.Base64URL;
033import com.nimbusds.oauth2.sdk.id.JWTID;
034import com.nimbusds.oauth2.sdk.util.singleuse.AlreadyUsedException;
035import com.nimbusds.oauth2.sdk.util.singleuse.SingleUseChecker;
036
037
038/**
039 * DPoP proof JWT single use checker. Caches a hash of the checked DPoP JWT
040 * "jti" (JWT ID) claims for a given DPoP issuer. The checker should be
041 * {@link #shutdown() shut down} when no longer in use.
042 */
043@ThreadSafe
044public class DefaultDPoPSingleUseChecker implements SingleUseChecker<Map.Entry<DPoPIssuer, JWTID>> {
045        
046        private final Timer timer;
047        
048        private final ConcurrentHashMap<String,Long> cachedJTIs = new ConcurrentHashMap<>();
049        
050        
051        /**
052         * Creates a new DPoP proof JWT single use checker.
053         *
054         * @param lifetimeSeconds      The lifetime of cached DPoP proof "jti"
055         *                             (JWT ID) claims, in seconds.
056         * @param purgeIntervalSeconds The interval in seconds for purging the
057         *                             cached "jti" (JWT ID) claims of checked
058         *                             DPoP proofs.
059         */
060        public DefaultDPoPSingleUseChecker(final long lifetimeSeconds,
061                                           final long purgeIntervalSeconds) {
062                
063                timer = new Timer("dpop-single-use-jti-cache-purge-task", true);
064                
065                timer.schedule(
066                        new TimerTask() {
067                                @Override
068                                public void run() {
069                                        final long nowMS = new Date().getTime();
070                                        final long expHorizon = nowMS - lifetimeSeconds * 1000;
071                                        for (Map.Entry<String, Long> en: cachedJTIs.entrySet()) {
072                                                if (en.getValue() < expHorizon) {
073                                                        cachedJTIs.remove(en.getKey());
074                                                }
075                                        }
076                                }
077                        },
078                        purgeIntervalSeconds * 1000,
079                        purgeIntervalSeconds * 1000);
080        }
081        
082        
083        /**
084         * Computes a SHA-256 hash for the specified access token.
085         *
086         * @param jti The access token. Must not be {@code null}.
087         *
088         * @return The hash, BASE64 URL encoded.
089         *
090         * @throws RuntimeException If hashing failed.
091         */
092        private static Base64URL computeSHA256(final JWTID jti) {
093                
094                byte[] hash;
095                try {
096                        MessageDigest md = MessageDigest.getInstance("SHA-256");
097                        hash = md.digest(jti.getValue().getBytes(StandardCharsets.UTF_8));
098                } catch (NoSuchAlgorithmException e) {
099                        throw new RuntimeException(e.getMessage(), e);
100                }
101                
102                return Base64URL.encode(hash);
103        }
104        
105        
106        @Override
107        public void markAsUsed(final Map.Entry<DPoPIssuer, JWTID> object)
108                throws AlreadyUsedException {
109                
110                String key = object.getKey().getValue() + ":" + computeSHA256(object.getValue());
111                
112                long nowMS = new Date().getTime();
113                
114                if (cachedJTIs.putIfAbsent(key, nowMS) != null) {
115                        throw new AlreadyUsedException("Detected jti replay");
116                }
117        }
118        
119        
120        /**
121         * Returns the number of cached items.
122         *
123         * @return The cached items, zero if none.
124         */
125        public int getCacheSize() {
126                
127                return cachedJTIs.size();
128        }
129        
130        
131        /**
132         * Shuts down this checker and frees any associated resources.
133         */
134        public void shutdown() {
135                
136                timer.cancel();
137        }
138}