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> — {@link #consume(String)} atomically removes the entry, so a replayed callback with the 040 * same {@code state} fails. 041 * <li><b>TTL-bounded</b> — entries older than the configured TTL are treated as a miss and swept. 042 * <li><b>Size-bounded</b> — 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}