001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.client.mcp.auth;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.net.*;
022import java.time.*;
023import java.util.*;
024
025/**
026 * Single-use, TTL-bounded store for the per-authorization {@code state} → {@code (codeVerifier, expectedIssuer)}
027 * association created when an interactive authorization-code + PKCE flow is started and consumed when the loopback
028 * redirect arrives.
029 *
030 * <p>
031 * Relocation of the {@code EphemeralStore} pattern from {@code juneau-rest-server-auth-oidc-rp} for headless
032 * (CLI/desktop) use.  The browser-session {@code redirectTarget}/{@code nonce} shape is dropped; this headless variant
033 * stores the PKCE {@code code_verifier} and the discovered {@code expectedIssuer} used for the SEP-2468 {@code iss}
034 * check on the callback.
035 *
036 * <p>
037 * Security properties:
038 * <ul>
039 *    <li><b>Single-use</b> &mdash; {@link #consume(String)} atomically removes the entry, so a replayed callback with the
040 *       same {@code state} fails.
041 *    <li><b>TTL-bounded</b> &mdash; entries older than the configured TTL are treated as a miss and swept.
042 *    <li><b>Size-bounded</b> &mdash; an LRU cap (default 1000) prevents unbounded growth from abandoned attempts.
043 * </ul>
044 *
045 * <p>
046 * Thread-safe.
047 *
048 * @since 10.0.0
049 */
050public class EphemeralStore {
051
052   /** Default maximum number of in-flight authorization attempts retained. */
053   public static final int DEFAULT_MAX_ENTRIES = 1_000;
054
055   /** Default time-to-live for a pending authorization (the user has at most a few minutes at the IdP). */
056   public static final Duration DEFAULT_TTL = Duration.ofMinutes(5);
057
058   /** Hard cap on the configurable TTL. */
059   static final Duration MAX_TTL = Duration.ofMinutes(30);
060
061   /**
062    * A pending-authorization association awaiting the IdP loopback callback.
063    *
064    * @param codeVerifier The PKCE {@code code_verifier} string.
065    * @param expectedIssuer The discovered issuer the callback {@code iss} parameter is validated against (SEP-2468).
066    *    May be {@code null} when no issuer was discovered.
067    * @param createdAt The instant the entry was stored.
068    */
069   public record PendingAuthorization(String codeVerifier, URI expectedIssuer, Instant createdAt) {
070
071      /**
072       * Redacts the PKCE {@code codeVerifier} so it never reaches logs via this record's {@code toString()}
073       * (mirrors the {@code OAuthToken} / {@code McpTokenProvider} redaction discipline).
074       *
075       * @return A redacted string form disclosing only non-secret metadata.
076       */
077      @Override
078      public String toString() {
079         return "PendingAuthorization[codeVerifier=<redacted>, expectedIssuer=" + expectedIssuer
080            + ", createdAt=" + createdAt + "]";
081      }
082   }
083
084   private final int maxEntries;
085   private final Duration ttl;
086   private final Clock clock;
087   private final Map<String,PendingAuthorization> entries;
088   private final Object lock = new Object();
089
090   /**
091    * Creates a store with the default TTL, size cap, and system UTC clock.
092    *
093    * @return A new store.
094    */
095   public static EphemeralStore create() {
096      return new EphemeralStore(DEFAULT_TTL, DEFAULT_MAX_ENTRIES, Clock.systemUTC());
097   }
098
099   /**
100    * Constructor.
101    *
102    * @param ttl The single-use TTL.  Must be positive and not exceed 30 minutes.
103    * @param maxEntries The LRU size cap.  Must be positive.
104    * @param clock The clock for TTL comparisons.  Must not be <jk>null</jk>.
105    */
106   public EphemeralStore(Duration ttl, int maxEntries, Clock clock) {
107      reqnn("ttl", ttl);
108      req(!ttl.isZero() && !ttl.isNegative(), "ttl must be positive");
109      req(ttl.compareTo(MAX_TTL) <= 0, "ttl must not exceed 30 minutes (was %s)", ttl);
110      req(maxEntries > 0, "maxEntries must be positive (was %s)", maxEntries);
111      this.ttl = ttl;
112      this.maxEntries = maxEntries;
113      this.clock = reqnn("clock", clock);
114      this.entries = new LinkedHashMap<>(16, 0.75f, true) {
115         private static final long serialVersionUID = 1L;
116         @Override
117         protected boolean removeEldestEntry(Map.Entry<String,PendingAuthorization> eldest) {
118            return size() > EphemeralStore.this.maxEntries;
119         }
120      };
121   }
122
123   /**
124    * Stores a pending-authorization association keyed by {@code state}.
125    *
126    * @param state The opaque {@code state} value.  Must not be <jk>null</jk> or blank.
127    * @param codeVerifier The PKCE {@code code_verifier}.  Must not be <jk>null</jk> or blank.
128    * @param expectedIssuer The discovered issuer for the SEP-2468 {@code iss} check.  May be <jk>null</jk>.
129    */
130   public void store(String state, String codeVerifier, URI expectedIssuer) {
131      reqnb("state", state);
132      reqnb("codeVerifier", codeVerifier);
133      var now = clock.instant();
134      synchronized (lock) {
135         sweepExpired(now);
136         entries.put(state, new PendingAuthorization(codeVerifier, expectedIssuer, now));
137      }
138   }
139
140   /**
141    * Atomically removes and returns the pending-authorization association for {@code state}, if present and
142    * not expired.
143    *
144    * @param state The {@code state} value from the callback.  Must not be <jk>null</jk>.
145    * @return The association, or {@link Optional#empty()} if absent or expired.
146    */
147   public Optional<PendingAuthorization> consume(String state) {
148      if (state == null)
149         return oe();
150      var now = clock.instant();
151      synchronized (lock) {
152         var p = entries.remove(state);
153         if (p == null)
154            return oe();
155         if (isExpired(p, now))
156            return oe();
157         return o(p);
158      }
159   }
160
161   /**
162    * Returns the current entry count.  Primarily for tests and metrics.
163    *
164    * @return The entry count.
165    */
166   public int size() {
167      synchronized (lock) {
168         return entries.size();
169      }
170   }
171
172   private boolean isExpired(PendingAuthorization p, Instant now) {
173      return !now.isBefore(p.createdAt().plus(ttl));
174   }
175
176   private void sweepExpired(Instant now) {
177      entries.values().removeIf(p -> isExpired(p, now));
178   }
179}