af-credentials 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- af_credentials/__init__.py +5 -0
- af_credentials/_version.py +24 -0
- af_credentials/_version.pyi +2 -0
- af_credentials/mcp.py +56 -0
- af_credentials/proxy.py +214 -0
- af_credentials/py.typed +0 -0
- af_credentials/verifier.py +161 -0
- af_credentials-0.1.0.dist-info/METADATA +248 -0
- af_credentials-0.1.0.dist-info/RECORD +11 -0
- af_credentials-0.1.0.dist-info/WHEEL +4 -0
- af_credentials-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
af_credentials/mcp.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Optional adapter from BrokerTokenVerifier to the mcp SDK's TokenVerifier protocol.
|
|
2
|
+
|
|
3
|
+
Importing this module requires the ``mcp`` package (the ``[mcp]`` extra --
|
|
4
|
+
see pyproject.toml and README.md); af_credentials.verifier itself never
|
|
5
|
+
imports it, so a caller who only needs token verification (no MCP server)
|
|
6
|
+
pays no cost for this optional dependency. The guard below turns a missing
|
|
7
|
+
``mcp`` install into one clear error naming the extra to install, rather
|
|
8
|
+
than a bare ``ModuleNotFoundError`` pointing at this file's internals.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from af_credentials.verifier import BrokerTokenVerifier
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
20
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
21
|
+
msg = ( # pylint: disable=invalid-name
|
|
22
|
+
"af_credentials.mcp requires the 'mcp' package. Install it via the "
|
|
23
|
+
"'mcp' extra: pip install af-credentials[mcp]"
|
|
24
|
+
)
|
|
25
|
+
raise ImportError(msg) from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BrokerTokenVerifierAdapter(TokenVerifier):
|
|
29
|
+
"""Adapts a BrokerTokenVerifier to the mcp SDK's TokenVerifier protocol.
|
|
30
|
+
|
|
31
|
+
Never constructed directly by callers -- use ``mcp_token_verifier()``.
|
|
32
|
+
Carries no authorization claims: ``scopes`` is always empty, matching
|
|
33
|
+
the AF Broker Identity Token itself carrying none (see verifier.py's
|
|
34
|
+
module docstring) -- an MCP server wanting authorization must resolve
|
|
35
|
+
it itself from ``client_id`` (the token's ``sub``), not from this
|
|
36
|
+
adapter's output.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, verifier: BrokerTokenVerifier) -> None:
|
|
40
|
+
self._verifier = verifier
|
|
41
|
+
|
|
42
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
43
|
+
claims = await self._verifier.verify(token)
|
|
44
|
+
if claims is None:
|
|
45
|
+
return None
|
|
46
|
+
return AccessToken(
|
|
47
|
+
token=token,
|
|
48
|
+
client_id=claims.sub,
|
|
49
|
+
scopes=[],
|
|
50
|
+
expires_at=claims.exp,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mcp_token_verifier(verifier: BrokerTokenVerifier) -> TokenVerifier:
|
|
55
|
+
"""Wrap *verifier* as an mcp SDK ``TokenVerifier`` for a FastMCP/mcp server's auth configuration."""
|
|
56
|
+
return _BrokerTokenVerifierAdapter(verifier)
|
af_credentials/proxy.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Client for redeeming a brokered x509/VOMS proxy.
|
|
2
|
+
|
|
3
|
+
Codes against a redeem contract the broker does not implement yet (issue
|
|
4
|
+
#112): ``POST {broker_url}/v1/credentials/x509/redeem``, bearer-authenticated
|
|
5
|
+
with an AF Broker Identity Token (see verifier.py), empty JSON body. A 200
|
|
6
|
+
response is::
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"pem": "<PEM-encoded proxy certificate + key>",
|
|
10
|
+
"dn": "<VOMS proxy subject DN>",
|
|
11
|
+
"voms_attributes": ["<VOMS FQAN>", ...],
|
|
12
|
+
"expires_at": "<ISO-8601 timestamp>",
|
|
13
|
+
"remaining_seconds": <int>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Mirrors the broker's own credential-brokering shape (x509/VOMS proxies
|
|
17
|
+
minted via ephemeral k8s Jobs, docs/auth.md's "Critical auth constraint"
|
|
18
|
+
section) without importing anything broker-side: this client only ever
|
|
19
|
+
talks HTTP to ``{broker_url}``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import stat
|
|
26
|
+
import tempfile
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
import httpx2
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from types import TracebackType
|
|
36
|
+
|
|
37
|
+
from typing_extensions import Self
|
|
38
|
+
|
|
39
|
+
_REDEEM_PATH = "/v1/credentials/x509/redeem"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProxyNotAvailableError(Exception):
|
|
43
|
+
"""No usable proxy is available for this caller right now.
|
|
44
|
+
|
|
45
|
+
Raised when the broker answers 404 (e.g. the caller has no linked
|
|
46
|
+
``.globus`` credential to mint a proxy from) or when it did mint one
|
|
47
|
+
but its remaining validity is below the caller's ``min_remaining``
|
|
48
|
+
floor -- in both cases the caller's fix is "try a different credential
|
|
49
|
+
or come back later," not "retry this exact call," which is what
|
|
50
|
+
distinguishes this from ``ProxyRedeemError``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, detail: str) -> None:
|
|
54
|
+
super().__init__(detail)
|
|
55
|
+
self.detail = detail
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ProxyRedeemError(Exception):
|
|
59
|
+
"""The broker rejected or failed the redeem call for a reason other than "no proxy available" (a non-404, non-200 response)."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
62
|
+
super().__init__(f"proxy redeem failed with status {status_code}: {detail}")
|
|
63
|
+
self.status_code = status_code
|
|
64
|
+
self.detail = detail
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_iso8601(value: str) -> datetime:
|
|
68
|
+
"""Parse an ISO-8601 timestamp, accepting a trailing Z on Python 3.10.
|
|
69
|
+
|
|
70
|
+
``datetime.fromisoformat`` only learned the ``Z`` suffix in 3.11; the
|
|
71
|
+
broker emits ``+00:00`` offsets today, but a UTC designator from another
|
|
72
|
+
issuer must not break the oldest supported interpreter.
|
|
73
|
+
"""
|
|
74
|
+
if value.endswith("Z"):
|
|
75
|
+
value = value[:-1] + "+00:00"
|
|
76
|
+
return datetime.fromisoformat(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ProxyHandle:
|
|
81
|
+
"""A materialized x509/VOMS proxy on disk.
|
|
82
|
+
|
|
83
|
+
A context manager: ``__exit__``/``close()`` deletes the underlying
|
|
84
|
+
file. Not cached by ``ProxyClient`` -- every ``proxy_file()`` call
|
|
85
|
+
returns its own handle over its own file (the broker itself caches the
|
|
86
|
+
proxy; this client does not cache handles across calls).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
path: Path
|
|
90
|
+
dn: str
|
|
91
|
+
expires_at: datetime
|
|
92
|
+
_closed: bool = field(default=False, init=False, repr=False)
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""Delete the underlying file. Safe to call more than once."""
|
|
96
|
+
if not self._closed:
|
|
97
|
+
self.path.unlink(missing_ok=True)
|
|
98
|
+
self._closed = True
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> Self:
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc: BaseException | None,
|
|
107
|
+
tb: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ProxyClient:
|
|
113
|
+
"""Redeems brokered x509/VOMS proxies from an AF MCP broker.
|
|
114
|
+
|
|
115
|
+
Materialized proxy files live under a private, 0700 directory created
|
|
116
|
+
lazily on first use (one per ``ProxyClient`` instance, reused across
|
|
117
|
+
calls); each file inside it is written 0600. *min_remaining* rejects a
|
|
118
|
+
freshly-redeemed proxy whose ``remaining_seconds`` is already below a
|
|
119
|
+
useful floor -- a caller who then retried "the credential I just got"
|
|
120
|
+
would just get the same near-expired proxy back (the broker caches it),
|
|
121
|
+
so this is reported as ``ProxyNotAvailableError`` rather than handed to
|
|
122
|
+
the caller as if it were usable.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
broker_url: str,
|
|
128
|
+
*,
|
|
129
|
+
timeout: float = 10.0,
|
|
130
|
+
min_remaining: float = 60.0,
|
|
131
|
+
http_client: httpx2.AsyncClient | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Construct a client against *broker_url* (e.g. ``https://mcp.af.uchicago.edu``).
|
|
134
|
+
|
|
135
|
+
*http_client*, when given, is used for the redeem call instead of a
|
|
136
|
+
short-lived client created per call -- primarily a test seam
|
|
137
|
+
(inject an ``httpx2.AsyncClient`` backed by ``httpx2.MockTransport``)
|
|
138
|
+
but also usable by callers who want connection pooling. The client
|
|
139
|
+
never closes an injected ``http_client``.
|
|
140
|
+
"""
|
|
141
|
+
self._broker_url = broker_url.rstrip("/")
|
|
142
|
+
self._timeout = timeout
|
|
143
|
+
self._min_remaining = min_remaining
|
|
144
|
+
self._http_client = http_client
|
|
145
|
+
self._dir: Path | None = None
|
|
146
|
+
|
|
147
|
+
async def proxy_file(self, bearer: str) -> ProxyHandle:
|
|
148
|
+
"""Redeem a proxy and materialize it as a private 0600 file, returning a handle whose ``close()`` deletes it."""
|
|
149
|
+
data = await self._redeem(bearer)
|
|
150
|
+
directory = self._ensure_dir()
|
|
151
|
+
fd, raw_path = tempfile.mkstemp(dir=directory, prefix="proxy-", suffix=".pem")
|
|
152
|
+
path = Path(raw_path)
|
|
153
|
+
try:
|
|
154
|
+
with os.fdopen(fd, "w") as pem_file:
|
|
155
|
+
pem_file.write(data["pem"])
|
|
156
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
157
|
+
except OSError:
|
|
158
|
+
path.unlink(missing_ok=True)
|
|
159
|
+
raise
|
|
160
|
+
return ProxyHandle(
|
|
161
|
+
path=path,
|
|
162
|
+
dn=data["dn"],
|
|
163
|
+
expires_at=_parse_iso8601(data["expires_at"]),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def pem_bytes(self, bearer: str) -> bytes:
|
|
167
|
+
"""Redeem a proxy and return its PEM material in-memory, without writing a file."""
|
|
168
|
+
data = await self._redeem(bearer)
|
|
169
|
+
pem: str = data["pem"]
|
|
170
|
+
return pem.encode()
|
|
171
|
+
|
|
172
|
+
def _ensure_dir(self) -> Path:
|
|
173
|
+
if self._dir is None:
|
|
174
|
+
self._dir = Path(tempfile.mkdtemp(prefix="af-credentials-proxy-"))
|
|
175
|
+
self._dir.chmod(
|
|
176
|
+
stat.S_IRWXU
|
|
177
|
+
) # 0700 -- belt-and-suspenders over mkdtemp's default
|
|
178
|
+
return self._dir
|
|
179
|
+
|
|
180
|
+
async def _redeem(self, bearer: str) -> dict[str, Any]:
|
|
181
|
+
url = f"{self._broker_url}{_REDEEM_PATH}"
|
|
182
|
+
headers = {"Authorization": f"Bearer {bearer}"}
|
|
183
|
+
if self._http_client is not None:
|
|
184
|
+
response = await self._http_client.post(
|
|
185
|
+
url, headers=headers, json={}, timeout=self._timeout
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
async with httpx2.AsyncClient(timeout=self._timeout) as client:
|
|
189
|
+
response = await client.post(url, headers=headers, json={})
|
|
190
|
+
|
|
191
|
+
if response.status_code == 404:
|
|
192
|
+
raise ProxyNotAvailableError(self._extract_detail(response))
|
|
193
|
+
if response.status_code != 200:
|
|
194
|
+
raise ProxyRedeemError(response.status_code, self._extract_detail(response))
|
|
195
|
+
|
|
196
|
+
data: dict[str, Any] = response.json()
|
|
197
|
+
remaining = data["remaining_seconds"]
|
|
198
|
+
if remaining < self._min_remaining:
|
|
199
|
+
raise ProxyNotAvailableError(
|
|
200
|
+
f"redeemed proxy has only {remaining}s remaining "
|
|
201
|
+
f"(minimum {self._min_remaining}s required)"
|
|
202
|
+
)
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _extract_detail(response: httpx2.Response) -> str:
|
|
207
|
+
try:
|
|
208
|
+
body = response.json()
|
|
209
|
+
except ValueError:
|
|
210
|
+
return response.text
|
|
211
|
+
if isinstance(body, dict) and "detail" in body:
|
|
212
|
+
detail: str = body["detail"]
|
|
213
|
+
return detail
|
|
214
|
+
return response.text
|
af_credentials/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Async verification of AF Broker Identity Tokens.
|
|
2
|
+
|
|
3
|
+
The token format is documented in docs/auth.md ("AF Broker Identity Token",
|
|
4
|
+
issue #162) and minted by ``af_mcp_broker.credentials.broker_issued``: an
|
|
5
|
+
identity assertion only -- ``iss``/``sub``/``aud``/``exp``/``iat``/``jti``
|
|
6
|
+
always present, ``uid``/``gid``/``unixname`` present only for targets whose
|
|
7
|
+
broker config sets ``include_posix``. Deliberately absent: capabilities,
|
|
8
|
+
groups, or any authorization claim -- this module surfaces nothing beyond
|
|
9
|
+
what the token itself carries.
|
|
10
|
+
|
|
11
|
+
This module has no dependency on af_mcp_broker or any web framework: it is
|
|
12
|
+
the client half of the contract, meant to be embedded in any backend that
|
|
13
|
+
trusts the broker as a token issuer (ami-mcp's broker mode, later
|
|
14
|
+
rucio-mcp), verifying against the broker's own published JWKS
|
|
15
|
+
(``GET /.well-known/jwks.json``) with a standard JWT library.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx2
|
|
25
|
+
import jwt
|
|
26
|
+
from jwt.algorithms import RSAAlgorithm
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class BrokerClaims:
|
|
31
|
+
"""Decoded, verified claims from an AF Broker Identity Token.
|
|
32
|
+
|
|
33
|
+
Mirrors the claim set ``BrokerTokenIssuer.mint()`` signs: ``sub``,
|
|
34
|
+
``jti``, and ``exp`` are always present on a verified token; ``uid``/
|
|
35
|
+
``gid``/``unixname`` are ``None`` unless the issuing broker included
|
|
36
|
+
POSIX identity claims for this token's audience.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
sub: str
|
|
40
|
+
jti: str
|
|
41
|
+
exp: int
|
|
42
|
+
uid: int | None = None
|
|
43
|
+
gid: int | None = None
|
|
44
|
+
unixname: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BrokerTokenVerifier:
|
|
48
|
+
"""Verifies AF Broker Identity Tokens (RS256) against a broker's published JWKS.
|
|
49
|
+
|
|
50
|
+
JWKS keys are cached in-process for *cache_ttl* seconds, keyed by
|
|
51
|
+
``kid``. A token whose ``kid`` isn't in the current cache triggers
|
|
52
|
+
exactly one refetch (to pick up a key rotated in since the last fetch,
|
|
53
|
+
per docs/auth.md's rotation procedure) -- if the refetched JWKS still
|
|
54
|
+
doesn't carry that ``kid``, verification fails without fetching again.
|
|
55
|
+
|
|
56
|
+
``verify()`` returns ``None`` for every way a token can be *invalid*
|
|
57
|
+
(bad signature, wrong issuer/audience, expired, malformed, unknown
|
|
58
|
+
key) so callers can treat "not authenticated" uniformly. It does NOT
|
|
59
|
+
catch transport-level failures (a network error, or the JWKS endpoint
|
|
60
|
+
itself returning a non-2xx status) -- those propagate as exceptions, so
|
|
61
|
+
a caller can distinguish "the broker is unreachable" from "this token
|
|
62
|
+
is bad" and respond accordingly (e.g. a 503 vs. a 401).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
jwks_url: str,
|
|
68
|
+
issuer: str,
|
|
69
|
+
audience: str,
|
|
70
|
+
*,
|
|
71
|
+
cache_ttl: float = 300.0,
|
|
72
|
+
http_client: httpx2.AsyncClient | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Construct a verifier for tokens issued by *issuer* naming *audience* as the audience.
|
|
75
|
+
|
|
76
|
+
*http_client*, when given, is used for every JWKS fetch instead of
|
|
77
|
+
a short-lived client created per fetch -- primarily a test seam
|
|
78
|
+
(inject an ``httpx2.AsyncClient`` backed by ``httpx2.MockTransport``)
|
|
79
|
+
but also usable by callers who want connection pooling across
|
|
80
|
+
verifiers. The verifier never closes an injected client; it owns
|
|
81
|
+
the lifecycle of one it creates itself, closing it after each
|
|
82
|
+
fetch.
|
|
83
|
+
"""
|
|
84
|
+
self._jwks_url = jwks_url
|
|
85
|
+
self._issuer = issuer
|
|
86
|
+
self._audience = audience
|
|
87
|
+
self._cache_ttl = cache_ttl
|
|
88
|
+
self._http_client = http_client
|
|
89
|
+
self._keys_by_kid: dict[str, dict[str, Any]] = {}
|
|
90
|
+
self._fetched_at: float | None = None
|
|
91
|
+
|
|
92
|
+
async def verify(self, token: str) -> BrokerClaims | None:
|
|
93
|
+
"""Verify *token* and return its claims, or ``None`` if it is not a currently-valid AF Broker Identity Token for this verifier's issuer/audience.
|
|
94
|
+
|
|
95
|
+
Raises whatever ``httpx2`` raises (connection errors, timeouts, a
|
|
96
|
+
non-2xx JWKS response) if a JWKS fetch is needed and fails -- see
|
|
97
|
+
the class docstring on why that is deliberately not folded into a
|
|
98
|
+
``None`` return.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
header = jwt.get_unverified_header(token)
|
|
102
|
+
except jwt.InvalidTokenError:
|
|
103
|
+
return None
|
|
104
|
+
kid = header.get("kid")
|
|
105
|
+
if not isinstance(kid, str):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
key_data = await self._get_key(kid)
|
|
109
|
+
if key_data is None:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
public_key = RSAAlgorithm.from_jwk(key_data)
|
|
114
|
+
claims = jwt.decode(
|
|
115
|
+
token,
|
|
116
|
+
public_key, # type: ignore[arg-type] # JWKS only ever carries public keys
|
|
117
|
+
algorithms=["RS256"],
|
|
118
|
+
issuer=self._issuer,
|
|
119
|
+
audience=self._audience,
|
|
120
|
+
options={"verify_exp": True},
|
|
121
|
+
)
|
|
122
|
+
except jwt.InvalidTokenError:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
return BrokerClaims(
|
|
126
|
+
sub=claims["sub"],
|
|
127
|
+
jti=claims["jti"],
|
|
128
|
+
exp=claims["exp"],
|
|
129
|
+
uid=claims.get("uid"),
|
|
130
|
+
gid=claims.get("gid"),
|
|
131
|
+
unixname=claims.get("unixname"),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def _get_key(self, kid: str) -> dict[str, Any] | None:
|
|
135
|
+
"""Return the JWK for *kid*, refreshing the cache if it is stale or missing *kid* -- with at most one refetch for a *kid* the refreshed JWKS still doesn't carry."""
|
|
136
|
+
now = time.monotonic()
|
|
137
|
+
cache_is_stale = (
|
|
138
|
+
self._fetched_at is None or (now - self._fetched_at) > self._cache_ttl
|
|
139
|
+
)
|
|
140
|
+
if cache_is_stale or kid not in self._keys_by_kid:
|
|
141
|
+
await self._refresh()
|
|
142
|
+
return self._keys_by_kid.get(kid)
|
|
143
|
+
|
|
144
|
+
async def _refresh(self) -> None:
|
|
145
|
+
"""Refetch the JWKS unconditionally and replace the key cache with its contents."""
|
|
146
|
+
keys = await self._fetch_jwks()
|
|
147
|
+
self._keys_by_kid = {
|
|
148
|
+
key_data["kid"]: key_data for key_data in keys if "kid" in key_data
|
|
149
|
+
}
|
|
150
|
+
self._fetched_at = time.monotonic()
|
|
151
|
+
|
|
152
|
+
async def _fetch_jwks(self) -> list[dict[str, Any]]:
|
|
153
|
+
if self._http_client is not None:
|
|
154
|
+
response = await self._http_client.get(self._jwks_url)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
return response.json()["keys"] # type: ignore[no-any-return]
|
|
157
|
+
|
|
158
|
+
async with httpx2.AsyncClient(timeout=10.0) as client:
|
|
159
|
+
response = await client.get(self._jwks_url)
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
return response.json()["keys"] # type: ignore[no-any-return]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: af-credentials
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Backend-side client for the AF MCP credential broker: identity-token verification and x509 proxy redemption
|
|
5
|
+
Project-URL: Documentation, https://af-credentials.readthedocs.io/
|
|
6
|
+
Project-URL: Homepage, https://github.com/maniaclab/af-credentials
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/maniaclab/af-credentials/issues
|
|
8
|
+
Project-URL: Discussions, https://github.com/maniaclab/af-credentials/discussions
|
|
9
|
+
Project-URL: Changelog, https://github.com/maniaclab/af-credentials/releases
|
|
10
|
+
Author-email: Giordon Stark <kratsg@gmail.com>
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Classifier: Development Status :: 1 - Planning
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: httpx2<3,>=2.5.0
|
|
29
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
30
|
+
Provides-Extra: mcp
|
|
31
|
+
Requires-Dist: mcp<3,>=2.0.0; extra == 'mcp'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# af-credentials v0.1.0
|
|
35
|
+
|
|
36
|
+
[![Actions Status][actions-badge]][actions-link]
|
|
37
|
+
[![Documentation Status][rtd-badge]][rtd-link]
|
|
38
|
+
|
|
39
|
+
[![PyPI version][pypi-version]][pypi-link]
|
|
40
|
+
[![PyPI platforms][pypi-platforms]][pypi-link]
|
|
41
|
+
|
|
42
|
+
[![GitHub Discussion][github-discussions-badge]][github-discussions-link]
|
|
43
|
+
|
|
44
|
+
[![Coverage][coverage-badge]][coverage-link]
|
|
45
|
+
|
|
46
|
+
<!-- --8<-- [start:intro] -->
|
|
47
|
+
|
|
48
|
+
Backend-side client for the AF MCP platform's broker-issued credentials (issue
|
|
49
|
+
#112). Import package: `af_credentials`. No dependency on `af_mcp_broker`,
|
|
50
|
+
FastAPI, or Kubernetes — this is meant to be embedded in _other_ MCP backends
|
|
51
|
+
that need to trust the broker (ami-mcp's broker mode today, later rucio-mcp), so
|
|
52
|
+
it stays deliberately thin: `pyjwt[crypto]` and `httpx2` at runtime,
|
|
53
|
+
`mcp>=2.0.0,<3` opt-in via the `[mcp]` extra.
|
|
54
|
+
|
|
55
|
+
<!-- --8<-- [end:intro] -->
|
|
56
|
+
|
|
57
|
+
<!-- --8<-- [start:installation] -->
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install af-credentials
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
With the optional `mcp` SDK adapter (`af_credentials.mcp`):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install af-credentials[mcp]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or with pixi:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pixi add af-credentials
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
<!-- --8<-- [end:installation] -->
|
|
78
|
+
|
|
79
|
+
<!-- --8<-- [start:requirements] -->
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- Python 3.10+
|
|
84
|
+
- An AF MCP broker publishing a JWKS endpoint (for `BrokerTokenVerifier`)
|
|
85
|
+
|
|
86
|
+
<!-- --8<-- [end:requirements] -->
|
|
87
|
+
|
|
88
|
+
<!-- --8<-- [start:usage] -->
|
|
89
|
+
|
|
90
|
+
## `BrokerTokenVerifier` (`af_credentials.verifier`)
|
|
91
|
+
|
|
92
|
+
Verifies an **AF Broker Identity Token** — the RS256 identity assertion
|
|
93
|
+
`af_mcp_broker.credentials.broker_issued.BrokerTokenIssuer` mints for AF-native
|
|
94
|
+
backends (see the platform's `docs/auth.md`, "AF Broker Identity Token"). The
|
|
95
|
+
claim set is exactly `iss`/`sub`/`aud`/`exp`/`iat`/`jti`, plus
|
|
96
|
+
`uid`/`gid`/`unixname` only when the issuing broker's target config requested
|
|
97
|
+
POSIX identity — never a capability or group claim.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from af_credentials.verifier import BrokerTokenVerifier
|
|
101
|
+
|
|
102
|
+
verifier = BrokerTokenVerifier(
|
|
103
|
+
jwks_url="https://mcp.af.uchicago.edu/.well-known/jwks.json",
|
|
104
|
+
issuer="https://mcp.af.uchicago.edu",
|
|
105
|
+
audience="ami-mcp",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
claims = await verifier.verify(token)
|
|
109
|
+
if claims is None:
|
|
110
|
+
... # not authenticated: bad signature, wrong iss/aud, expired, ...
|
|
111
|
+
else:
|
|
112
|
+
claims.sub, claims.jti, claims.exp # always present
|
|
113
|
+
claims.uid, claims.gid, claims.unixname # None unless this token carries POSIX identity
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
JWKS keys are cached in-process for `cache_ttl` seconds (default 300), keyed by
|
|
117
|
+
`kid`. A token whose `kid` isn't in the current cache triggers **exactly one**
|
|
118
|
+
refetch, to pick up a key rotated in since the last fetch (see the platform's
|
|
119
|
+
key-rotation procedure) — if the refetched JWKS still doesn't carry that `kid`,
|
|
120
|
+
verification fails without fetching again.
|
|
121
|
+
|
|
122
|
+
`verify()` returns `None` for every way a token can be _invalid_ (bad signature,
|
|
123
|
+
wrong issuer/audience, expired, malformed, unknown key), so callers can treat
|
|
124
|
+
"not authenticated" uniformly. It does **not** catch transport failures — a JWKS
|
|
125
|
+
fetch that can't connect, times out, or gets a non-2xx response raises the
|
|
126
|
+
underlying `httpx2` exception, so a caller can tell "the broker is unreachable"
|
|
127
|
+
apart from "this token is bad" and respond accordingly (e.g. a 503 vs. a 401).
|
|
128
|
+
|
|
129
|
+
### `mcp_token_verifier()` (`af_credentials.mcp`, requires the `[mcp]` extra)
|
|
130
|
+
|
|
131
|
+
Adapts a `BrokerTokenVerifier` to the `mcp` SDK's `TokenVerifier` protocol, for
|
|
132
|
+
wiring an AF Broker Identity Token straight into a FastMCP/mcp server's auth
|
|
133
|
+
configuration:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from af_credentials.mcp import mcp_token_verifier
|
|
137
|
+
|
|
138
|
+
token_verifier = mcp_token_verifier(
|
|
139
|
+
verifier
|
|
140
|
+
) # implements mcp.server.auth.provider.TokenVerifier
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`verify_token(token)` returns
|
|
144
|
+
`AccessToken(token=token, client_id=claims.sub, scopes=[], expires_at=claims.exp)`
|
|
145
|
+
or `None`. `scopes` is always empty — the token itself carries no authorization
|
|
146
|
+
claims, so a server wanting authorization must resolve it from `client_id` (the
|
|
147
|
+
token's `sub`) itself, not from this adapter's output.
|
|
148
|
+
|
|
149
|
+
## `ProxyClient` (`af_credentials.proxy`)
|
|
150
|
+
|
|
151
|
+
Redeems a brokered x509/VOMS proxy. **Codes against a contract the broker does
|
|
152
|
+
not implement yet** (issue #112) — the redeem endpoint below is a specification
|
|
153
|
+
for the broker-side work to land against, not a live API.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from af_credentials.proxy import ProxyClient, ProxyNotAvailableError, ProxyRedeemError
|
|
157
|
+
|
|
158
|
+
client = ProxyClient("https://mcp.af.uchicago.edu")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
with await client.proxy_file(bearer_token) as handle:
|
|
162
|
+
# handle.path -> Path to a private 0600 PEM file (proxy cert + key)
|
|
163
|
+
# handle.dn -> VOMS proxy subject DN
|
|
164
|
+
# handle.expires_at -> datetime
|
|
165
|
+
run_subprocess(env={"X509_USER_PROXY": str(handle.path)})
|
|
166
|
+
# file is deleted here, on __exit__
|
|
167
|
+
except ProxyNotAvailableError:
|
|
168
|
+
... # no proxy available for this caller right now (no linked .globus,
|
|
169
|
+
# or the broker's own cached proxy is too close to expiry)
|
|
170
|
+
except ProxyRedeemError as exc:
|
|
171
|
+
... # the broker rejected/failed the call; exc.status_code, exc.detail
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Use `pem_bytes(bearer_token)` instead of `proxy_file()` when the caller wants
|
|
175
|
+
the PEM material in-memory rather than as a file.
|
|
176
|
+
|
|
177
|
+
### The redeem contract
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
POST {broker_url}/v1/credentials/x509/redeem
|
|
181
|
+
Authorization: Bearer <token>
|
|
182
|
+
Content-Type: application/json
|
|
183
|
+
|
|
184
|
+
{}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A 200 response:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"pem": "<PEM-encoded proxy certificate + key>",
|
|
192
|
+
"dn": "<VOMS proxy subject DN>",
|
|
193
|
+
"voms_attributes": ["<VOMS FQAN>", "..."],
|
|
194
|
+
"expires_at": "<ISO-8601 timestamp>",
|
|
195
|
+
"remaining_seconds": 3600
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- **404** → `ProxyNotAvailableError(detail)` — the response's `detail` field (or
|
|
200
|
+
raw body if not JSON) is the exception's `.detail`.
|
|
201
|
+
- Any other non-200 → `ProxyRedeemError(status_code, detail)`.
|
|
202
|
+
- A 200 response whose `remaining_seconds` is below the client's `min_remaining`
|
|
203
|
+
(default 60s) is _also_ treated as `ProxyNotAvailableError` — the broker
|
|
204
|
+
caches the proxy itself, so a caller who retried "the credential I just got"
|
|
205
|
+
would just get the same near-expired proxy back.
|
|
206
|
+
|
|
207
|
+
`ProxyClient` never caches handles across calls — every `proxy_file()`/
|
|
208
|
+
`pem_bytes()` call redeems fresh (the broker is expected to be the one doing the
|
|
209
|
+
caching). Materialized files live under a private, 0700 directory created lazily
|
|
210
|
+
on first use and reused for the lifetime of the `ProxyClient` instance; each
|
|
211
|
+
file inside it is written 0600.
|
|
212
|
+
|
|
213
|
+
<!-- --8<-- [end:usage] -->
|
|
214
|
+
|
|
215
|
+
<!-- --8<-- [start:development] -->
|
|
216
|
+
|
|
217
|
+
## Development
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
git clone https://github.com/maniaclab/af-credentials
|
|
221
|
+
cd af-credentials
|
|
222
|
+
pixi install
|
|
223
|
+
pixi run pre-commit-install
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pixi run test # run tests
|
|
228
|
+
pixi run lint # pre-commit + pylint
|
|
229
|
+
pixi run build # build sdist + wheel
|
|
230
|
+
pixi run docs-serve # build and serve docs locally
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
<!-- --8<-- [end:development] -->
|
|
234
|
+
|
|
235
|
+
<!-- prettier-ignore-start -->
|
|
236
|
+
[actions-badge]: https://github.com/maniaclab/af-credentials/actions/workflows/ci.yml/badge.svg
|
|
237
|
+
[actions-link]: https://github.com/maniaclab/af-credentials/actions
|
|
238
|
+
[github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github
|
|
239
|
+
[github-discussions-link]: https://github.com/maniaclab/af-credentials/discussions
|
|
240
|
+
[pypi-link]: https://pypi.org/project/af-credentials/
|
|
241
|
+
[pypi-platforms]: https://img.shields.io/pypi/pyversions/af-credentials
|
|
242
|
+
[pypi-version]: https://img.shields.io/pypi/v/af-credentials
|
|
243
|
+
[rtd-badge]: https://readthedocs.org/projects/af-credentials/badge/?version=latest
|
|
244
|
+
[rtd-link]: https://af-credentials.readthedocs.io/en/latest/?badge=latest
|
|
245
|
+
[coverage-badge]: https://codecov.io/github/maniaclab/af-credentials/branch/main/graph/badge.svg
|
|
246
|
+
[coverage-link]: https://codecov.io/github/maniaclab/af-credentials
|
|
247
|
+
|
|
248
|
+
<!-- prettier-ignore-end -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
af_credentials/__init__.py,sha256=7tonT-iauV7s6Va9zOc2dBryOiJWYk6Xlh-85n_cFYY,122
|
|
2
|
+
af_credentials/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
af_credentials/_version.pyi,sha256=o7uNL6MhuJoiqpEnriU7rBT6TmkJZA-i2qMoNz9YcgQ,82
|
|
4
|
+
af_credentials/mcp.py,sha256=BzZVTcXXpKtwCE_Pv3ghsXWGYWXkcWUd30lSbIJL1e0,2233
|
|
5
|
+
af_credentials/proxy.py,sha256=bT58uE0uOuZYklwV9xbLSrOiKMOeVWfMoHuVJZoysSo,7804
|
|
6
|
+
af_credentials/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
af_credentials/verifier.py,sha256=uO6_LN3XjOT3CLKGMBwPClJV00ubYO9vlbDadRggBgc,6490
|
|
8
|
+
af_credentials-0.1.0.dist-info/METADATA,sha256=NWp4JJ7y94qWMTl9pSy9GOxOUHiXhlTGFYdG336kOxw,9289
|
|
9
|
+
af_credentials-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
af_credentials-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
11
|
+
af_credentials-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|