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 019 020import java.net.*; 021import java.util.*; 022import java.util.concurrent.*; 023import static org.apache.juneau.commons.utils.Shorts.*; 024 025/** 026 * Computes and tracks the SEP-2350 step-up scope union — the set of scopes a client re-authorizes with after a 027 * server emits a per-operation {@code insufficient_scope} challenge. 028 * 029 * <p> 030 * The <b>mandated minimum</b> union (SEP-2350 "Step-Up Authorization Flow" step 2) is 031 * <code>{previously-requested} ∪ {challenge scopes}</code>, which preserves previously-granted permissions when 032 * servers emit per-operation challenges. {@link #union(Collection, Collection)} computes exactly that. The broader 033 * overload {@link #union(Collection, Collection, Collection, Collection, Collection)} additionally folds in the 034 * <i>opt-in</i> contributors the spec permits a client to consult (granted-token scopes, cached PRM 035 * {@code scopes_supported}, and client-metadata scope) — supplying them widens the union; omitting them keeps the 036 * default at the mandated minimum. 037 * 038 * <p> 039 * The union is <b>order-preserving</b> ({@link LinkedHashSet}) with <b>exact-string dedup only</b> — scopes are 040 * opaque strings, so {@code repo} and {@code repo:read} are both retained (no hierarchy-aware dedup client-side; 041 * hierarchy reasoning is the <i>server's</i> MUST). 042 * 043 * <p> 044 * As an instance, this class also maintains per-<code>(resource, issuer)</code> accumulated state so a step-up driver 045 * can carry the growing union across successive challenges for the same resource/AS. Thread-safe. 046 * 047 * @since 10.0.0 048 */ 049@SuppressWarnings({ 050 "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants 051}) 052public class McpScopeAccumulator { 053 054 private final ConcurrentMap<String,Set<String>> byKey = new ConcurrentHashMap<>(); 055 056 /** 057 * Computes the mandated-minimum step-up union: {@code previouslyRequested ∪ challengeScopes}, order-preserving with 058 * exact-string dedup. 059 * 060 * @param previouslyRequested The scope set the client previously requested (may be {@code null} / empty). 061 * @param challengeScopes The scopes from the server's {@code insufficient_scope} challenge (may be {@code null} / 062 * empty). 063 * @return A new order-preserving union set. Never {@code null}. 064 */ 065 public static Set<String> union(Collection<String> previouslyRequested, Collection<String> challengeScopes) { 066 return union(previouslyRequested, challengeScopes, null, null, null); 067 } 068 069 /** 070 * Computes the broadened step-up union, folding in the opt-in contributors the SEP-2350 Scope Selection Strategy 071 * permits a client to consult. Order-preserving; exact-string dedup. Any {@code null} contributor is treated as 072 * empty. 073 * 074 * @param previouslyRequested The scope set the client previously requested. 075 * @param challengeScopes The scopes from the server's {@code insufficient_scope} challenge. 076 * @param grantedTokenScopes The scopes on the currently-held token (opt-in). 077 * @param prmScopesSupported The cached PRM {@code scopes_supported} (opt-in). 078 * @param clientMetadataScope The scope declared in client metadata (opt-in). 079 * @return A new order-preserving union set. Never {@code null}. 080 */ 081 public static Set<String> union(Collection<String> previouslyRequested, Collection<String> challengeScopes, 082 Collection<String> grantedTokenScopes, Collection<String> prmScopesSupported, Collection<String> clientMetadataScope) { 083 var out = new LinkedHashSet<String>(); 084 addAll(out, previouslyRequested); 085 addAll(out, challengeScopes); 086 addAll(out, grantedTokenScopes); 087 addAll(out, prmScopesSupported); 088 addAll(out, clientMetadataScope); 089 return out; 090 } 091 092 /** 093 * Seeds the accumulated state for a {@code (resource, issuer)} key with the client's previously-requested scopes, 094 * <b>unioning</b> into any existing entry for that key rather than replacing it. 095 * 096 * <p> 097 * Unioning (not overwriting) matters when a shared accumulator is seeded more than once for the same key — 098 * e.g. two {@link McpStepUpAuthorizer.Builder#build() builds} against one shared accumulator, or a re-seed after some 099 * challenge scopes were already {@link #accumulate accumulated} — so no previously-tracked scope is ever lost. 100 * 101 * @param resource The RFC 8707 resource indicator. Must not be <jk>null</jk>. 102 * @param issuer The authorization server issuer. Must not be <jk>null</jk>. 103 * @param previouslyRequested The previously-requested scopes. May be {@code null} / empty. 104 * @return This object. 105 */ 106 public McpScopeAccumulator seed(URI resource, URI issuer, Collection<String> previouslyRequested) { 107 reqnn("resource", resource); 108 reqnn("issuer", issuer); 109 byKey.merge(key(resource, issuer), union(previouslyRequested, null), McpScopeAccumulator::union); 110 return this; 111 } 112 113 /** 114 * Unions the given challenge scopes into the accumulated state for the {@code (resource, issuer)} key and returns 115 * the updated union. 116 * 117 * @param resource The RFC 8707 resource indicator. Must not be <jk>null</jk>. 118 * @param issuer The authorization server issuer. Must not be <jk>null</jk>. 119 * @param challengeScopes The scopes from the latest {@code insufficient_scope} challenge. May be {@code null} / 120 * empty. 121 * @return A copy of the updated accumulated union for the key. Never {@code null}. 122 */ 123 public Set<String> accumulate(URI resource, URI issuer, Collection<String> challengeScopes) { 124 reqnn("resource", resource); 125 reqnn("issuer", issuer); 126 var updated = byKey.compute(key(resource, issuer), (k, existing) -> union(existing, challengeScopes)); 127 return new LinkedHashSet<>(updated); 128 } 129 130 /** 131 * Returns a copy of the currently-accumulated union for the {@code (resource, issuer)} key. 132 * 133 * @param resource The RFC 8707 resource indicator. Must not be <jk>null</jk>. 134 * @param issuer The authorization server issuer. Must not be <jk>null</jk>. 135 * @return A copy of the current accumulated union (empty if the key was never seeded/accumulated). 136 */ 137 public Set<String> current(URI resource, URI issuer) { 138 reqnn("resource", resource); 139 reqnn("issuer", issuer); 140 return new LinkedHashSet<>(byKey.getOrDefault(key(resource, issuer), Set.of())); 141 } 142 143 private static void addAll(Set<String> out, Collection<String> in) { 144 if (in != null) 145 for (var s : in) 146 if (inb(s)) 147 out.add(s); 148 } 149 150 private static String key(URI resource, URI issuer) { 151 return resource.toString() + '\n' + issuer.toString(); 152 } 153}