hoja-cli 0.21.4__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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
hoja_server/mcp_oauth.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""OAuth-backed remote Hoja MCP server.
|
|
2
|
+
|
|
3
|
+
Authorization and tokens are persisted by the existing SQLAlchemy database. The
|
|
4
|
+
MCP SDK owns protocol serialization, metadata, DCR, PKCE verification, and HTTP
|
|
5
|
+
transport; this module owns Hoja identity binding and consent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from urllib.parse import urlencode
|
|
16
|
+
|
|
17
|
+
from mcp.server.auth.provider import (
|
|
18
|
+
AccessToken,
|
|
19
|
+
AuthorizationCode,
|
|
20
|
+
AuthorizationParams,
|
|
21
|
+
AuthorizeError,
|
|
22
|
+
OAuthAuthorizationServerProvider,
|
|
23
|
+
RefreshToken,
|
|
24
|
+
RegistrationError,
|
|
25
|
+
TokenError,
|
|
26
|
+
)
|
|
27
|
+
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
|
28
|
+
from mcp.server.mcpserver import MCPServer
|
|
29
|
+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
30
|
+
from sqlalchemy import func, select, update
|
|
31
|
+
from starlette.requests import Request
|
|
32
|
+
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
33
|
+
|
|
34
|
+
from hoja_server import db
|
|
35
|
+
from hoja_server import tenant_context
|
|
36
|
+
from hoja_server.denials import DENY_HINT
|
|
37
|
+
from hoja_server.mcp_tools import INSTRUCTIONS as MCP_INSTRUCTIONS
|
|
38
|
+
|
|
39
|
+
READ_SCOPE = "hoja:read"
|
|
40
|
+
WRITE_SCOPE = "hoja:write"
|
|
41
|
+
ALL_SCOPES = [READ_SCOPE, WRITE_SCOPE]
|
|
42
|
+
ACCESS_TTL = 900
|
|
43
|
+
REFRESH_TTL = 60 * 60 * 24 * 30
|
|
44
|
+
CODE_TTL = 300
|
|
45
|
+
MAX_REDIRECT_URIS = 5
|
|
46
|
+
MAX_REDIRECT_URI_LENGTH = 2048
|
|
47
|
+
MAX_CLIENT_NAME_LENGTH = 200
|
|
48
|
+
MAX_OAUTH_CLIENTS = 10_000
|
|
49
|
+
MAX_REGISTRATIONS_PER_HOUR = 100
|
|
50
|
+
_REGISTRATION_WINDOW_SECONDS = 3600
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _take_registration_slot(engine) -> None:
|
|
54
|
+
"""Database-wide sliding-window limit on dynamic client registrations (shared across workers)."""
|
|
55
|
+
now = time.time()
|
|
56
|
+
key_hash = db.rate_limit_key_hash("global")
|
|
57
|
+
with engine.begin() as conn:
|
|
58
|
+
conn.execute(
|
|
59
|
+
db.rate_limit_event.delete().where(
|
|
60
|
+
db.rate_limit_event.c.bucket == "oauth_dcr",
|
|
61
|
+
db.rate_limit_event.c.created_at <= now - _REGISTRATION_WINDOW_SECONDS,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
count = conn.execute(
|
|
65
|
+
select(func.count())
|
|
66
|
+
.select_from(db.rate_limit_event)
|
|
67
|
+
.where(db.rate_limit_event.c.bucket == "oauth_dcr")
|
|
68
|
+
).scalar_one()
|
|
69
|
+
if count >= MAX_REGISTRATIONS_PER_HOUR:
|
|
70
|
+
raise RegistrationError(
|
|
71
|
+
"invalid_client_metadata", "Too many client registrations; try again later."
|
|
72
|
+
)
|
|
73
|
+
conn.execute(
|
|
74
|
+
db.rate_limit_event.insert().values(bucket="oauth_dcr", key_hash=key_hash, created_at=now)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _now() -> datetime:
|
|
79
|
+
return datetime.now(timezone.utc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _iso(value: datetime) -> str:
|
|
83
|
+
return value.isoformat()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _digest(raw: str) -> str:
|
|
87
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _secret() -> str:
|
|
91
|
+
return secrets.token_urlsafe(48)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HojaOAuthProvider(OAuthAuthorizationServerProvider):
|
|
95
|
+
"""SDK OAuth provider. Raw bearer values are never persisted."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, engine, store, trusted_proxy: bool = False):
|
|
98
|
+
self.engine = engine
|
|
99
|
+
self.store = store
|
|
100
|
+
self.trusted_proxy = trusted_proxy
|
|
101
|
+
from hoja_server.writer import Writer
|
|
102
|
+
|
|
103
|
+
self.writer = Writer(engine)
|
|
104
|
+
|
|
105
|
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
106
|
+
with self.engine.connect() as conn:
|
|
107
|
+
raw = conn.execute(
|
|
108
|
+
select(db.oauth_client.c.client_json).where(
|
|
109
|
+
db.oauth_client.c.client_id == client_id
|
|
110
|
+
)
|
|
111
|
+
).scalar_one_or_none()
|
|
112
|
+
return OAuthClientInformationFull.model_validate_json(raw) if raw else None
|
|
113
|
+
|
|
114
|
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
115
|
+
if not self.trusted_proxy:
|
|
116
|
+
raise RegistrationError(
|
|
117
|
+
"invalid_client_metadata", "MCP OAuth is not enabled on this deployment."
|
|
118
|
+
)
|
|
119
|
+
if not client_info.redirect_uris:
|
|
120
|
+
raise RegistrationError(
|
|
121
|
+
"invalid_redirect_uri", "At least one redirect URI is required."
|
|
122
|
+
)
|
|
123
|
+
if len(client_info.redirect_uris) > MAX_REDIRECT_URIS:
|
|
124
|
+
raise RegistrationError("invalid_redirect_uri", "Too many redirect URIs.")
|
|
125
|
+
if client_info.client_name and len(client_info.client_name) > MAX_CLIENT_NAME_LENGTH:
|
|
126
|
+
raise RegistrationError("invalid_client_metadata", "client_name is too long.")
|
|
127
|
+
if client_info.token_endpoint_auth_method not in (None, "none"):
|
|
128
|
+
raise RegistrationError(
|
|
129
|
+
"invalid_client_metadata", "Only public OAuth clients are supported."
|
|
130
|
+
)
|
|
131
|
+
for uri in client_info.redirect_uris:
|
|
132
|
+
text = str(uri)
|
|
133
|
+
if len(text) > MAX_REDIRECT_URI_LENGTH:
|
|
134
|
+
raise RegistrationError("invalid_redirect_uri", "Redirect URI is too long.")
|
|
135
|
+
if uri.scheme != "https" and not _is_loopback_uri(text):
|
|
136
|
+
raise RegistrationError(
|
|
137
|
+
"invalid_redirect_uri", "Redirect URI must use HTTPS or native loopback."
|
|
138
|
+
)
|
|
139
|
+
if uri.username or uri.password or uri.fragment:
|
|
140
|
+
raise RegistrationError(
|
|
141
|
+
"invalid_redirect_uri", "Redirect URI contains a forbidden component."
|
|
142
|
+
)
|
|
143
|
+
if client_info.scope:
|
|
144
|
+
requested = set(client_info.scope.split())
|
|
145
|
+
if not requested.issubset(ALL_SCOPES):
|
|
146
|
+
raise RegistrationError("invalid_client_metadata", "Unsupported OAuth scope.")
|
|
147
|
+
clean = client_info.model_copy(
|
|
148
|
+
update={"client_secret": None, "token_endpoint_auth_method": "none"}
|
|
149
|
+
)
|
|
150
|
+
_take_registration_slot(self.engine)
|
|
151
|
+
with self.engine.begin() as conn:
|
|
152
|
+
total = conn.execute(select(func.count()).select_from(db.oauth_client)).scalar_one()
|
|
153
|
+
if total >= MAX_OAUTH_CLIENTS:
|
|
154
|
+
raise RegistrationError(
|
|
155
|
+
"invalid_client_metadata", "Client registration limit reached."
|
|
156
|
+
)
|
|
157
|
+
conn.execute(
|
|
158
|
+
db.oauth_client.insert().values(
|
|
159
|
+
client_id=clean.client_id,
|
|
160
|
+
client_json=clean.model_dump_json(),
|
|
161
|
+
created_at=db.now_iso(),
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def authorize(
|
|
166
|
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
|
167
|
+
) -> str:
|
|
168
|
+
if not self.trusted_proxy:
|
|
169
|
+
raise AuthorizeError(
|
|
170
|
+
"temporarily_unavailable", "MCP OAuth is not enabled on this deployment."
|
|
171
|
+
)
|
|
172
|
+
if params.resource and self._resource_url() != params.resource.rstrip("/"):
|
|
173
|
+
raise AuthorizeError("invalid_target", "Unknown resource.")
|
|
174
|
+
scopes = params.scopes or [READ_SCOPE]
|
|
175
|
+
if not set(scopes).issubset(ALL_SCOPES):
|
|
176
|
+
raise AuthorizeError("invalid_scope", "Unsupported scope.")
|
|
177
|
+
# OAuth SDK validates exact registered redirect and requires S256 before this hook.
|
|
178
|
+
pending_id, csrf_token = _secret(), _secret()
|
|
179
|
+
expires = _now() + timedelta(minutes=5)
|
|
180
|
+
with self.engine.begin() as conn:
|
|
181
|
+
conn.execute(db.oauth_pending.insert().values(
|
|
182
|
+
pending_hash=_digest(pending_id), client_id=client.client_id,
|
|
183
|
+
scopes=json.dumps(scopes), code_challenge=params.code_challenge,
|
|
184
|
+
redirect_uri=str(params.redirect_uri),
|
|
185
|
+
redirect_explicit=params.redirect_uri_provided_explicitly,
|
|
186
|
+
state=params.state, csrf_token=csrf_token, resource=params.resource,
|
|
187
|
+
expires_at=_iso(expires), consumed_at=None,
|
|
188
|
+
))
|
|
189
|
+
return f"/oauth/consent?{urlencode({'pending': pending_id})}"
|
|
190
|
+
|
|
191
|
+
async def load_authorization_code(self, client, authorization_code: str):
|
|
192
|
+
with self.engine.connect() as conn:
|
|
193
|
+
row = (
|
|
194
|
+
conn.execute(
|
|
195
|
+
select(db.oauth_authorization).where(
|
|
196
|
+
db.oauth_authorization.c.code_hash == _digest(authorization_code),
|
|
197
|
+
db.oauth_authorization.c.client_id == client.client_id,
|
|
198
|
+
db.oauth_authorization.c.consumed_at.is_(None),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
.mappings()
|
|
202
|
+
.first()
|
|
203
|
+
)
|
|
204
|
+
if not row or row["expires_at"] <= _iso(_now()):
|
|
205
|
+
return None
|
|
206
|
+
return AuthorizationCode(
|
|
207
|
+
code=authorization_code,
|
|
208
|
+
scopes=json.loads(row["scopes"]),
|
|
209
|
+
expires_at=_parse_dt(row["expires_at"]).timestamp(),
|
|
210
|
+
client_id=row["client_id"],
|
|
211
|
+
code_challenge=row["code_challenge"],
|
|
212
|
+
redirect_uri=row["redirect_uri"],
|
|
213
|
+
redirect_uri_provided_explicitly=True,
|
|
214
|
+
subject=json.dumps({"user_id": row["user_id"], "org_id": row["organization_id"]}),
|
|
215
|
+
resource=row["resource"],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def exchange_authorization_code(
|
|
219
|
+
self, client, authorization_code: AuthorizationCode
|
|
220
|
+
) -> OAuthToken:
|
|
221
|
+
now = _now()
|
|
222
|
+
code_hash = _digest(authorization_code.code)
|
|
223
|
+
with self.engine.begin() as conn:
|
|
224
|
+
row = (
|
|
225
|
+
conn.execute(
|
|
226
|
+
select(db.oauth_authorization)
|
|
227
|
+
.where(
|
|
228
|
+
db.oauth_authorization.c.code_hash == code_hash,
|
|
229
|
+
db.oauth_authorization.c.client_id == client.client_id,
|
|
230
|
+
db.oauth_authorization.c.consumed_at.is_(None),
|
|
231
|
+
db.oauth_authorization.c.expires_at > _iso(now),
|
|
232
|
+
)
|
|
233
|
+
.with_for_update()
|
|
234
|
+
)
|
|
235
|
+
.mappings()
|
|
236
|
+
.first()
|
|
237
|
+
)
|
|
238
|
+
if not row:
|
|
239
|
+
raise TokenError("invalid_grant", "Authorization code is invalid or already used.")
|
|
240
|
+
consumed = conn.execute(
|
|
241
|
+
update(db.oauth_authorization)
|
|
242
|
+
.where(
|
|
243
|
+
db.oauth_authorization.c.code_hash == code_hash,
|
|
244
|
+
db.oauth_authorization.c.consumed_at.is_(None),
|
|
245
|
+
)
|
|
246
|
+
.values(consumed_at=_iso(now))
|
|
247
|
+
)
|
|
248
|
+
if consumed.rowcount != 1:
|
|
249
|
+
raise TokenError("invalid_grant", "Authorization code is invalid or already used.")
|
|
250
|
+
return self._mint_pair(
|
|
251
|
+
conn,
|
|
252
|
+
row["client_id"],
|
|
253
|
+
row["user_id"],
|
|
254
|
+
row["organization_id"],
|
|
255
|
+
json.loads(row["scopes"]),
|
|
256
|
+
authorization_code.resource,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def load_refresh_token(self, client, refresh_token: str):
|
|
260
|
+
with self.engine.begin() as conn:
|
|
261
|
+
row = (
|
|
262
|
+
conn.execute(
|
|
263
|
+
select(db.oauth_refresh_token).where(
|
|
264
|
+
db.oauth_refresh_token.c.token_hash == _digest(refresh_token),
|
|
265
|
+
db.oauth_refresh_token.c.client_id == client.client_id,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
.mappings()
|
|
269
|
+
.first()
|
|
270
|
+
)
|
|
271
|
+
if row and row["revoked_at"] is not None:
|
|
272
|
+
# The SDK rejects revoked tokens before exchange_refresh_token runs, so
|
|
273
|
+
# reuse of a rotated/revoked token must be handled here.
|
|
274
|
+
now = _iso(_now())
|
|
275
|
+
for target in (db.oauth_access_token, db.oauth_refresh_token):
|
|
276
|
+
conn.execute(
|
|
277
|
+
update(target)
|
|
278
|
+
.where(target.c.grant_id == row["grant_id"], target.c.revoked_at.is_(None))
|
|
279
|
+
.values(revoked_at=now)
|
|
280
|
+
)
|
|
281
|
+
return None
|
|
282
|
+
if not row or row["expires_at"] <= _iso(_now()):
|
|
283
|
+
return None
|
|
284
|
+
subject = json.dumps({"user_id": row["user_id"], "org_id": row["organization_id"], "grant_id": row["grant_id"]})
|
|
285
|
+
return RefreshToken(
|
|
286
|
+
token=refresh_token,
|
|
287
|
+
client_id=row["client_id"],
|
|
288
|
+
scopes=json.loads(row["scopes"]),
|
|
289
|
+
expires_at=int(_parse_dt(row["expires_at"]).timestamp()),
|
|
290
|
+
resource=row["resource"],
|
|
291
|
+
subject=subject,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def exchange_refresh_token(
|
|
295
|
+
self, client, refresh_token: RefreshToken, scopes: list[str]
|
|
296
|
+
) -> OAuthToken:
|
|
297
|
+
original = set(refresh_token.scopes)
|
|
298
|
+
requested = set(scopes or refresh_token.scopes)
|
|
299
|
+
if not requested.issubset(original):
|
|
300
|
+
raise TokenError("invalid_scope", "Refresh cannot increase granted scopes.")
|
|
301
|
+
replay_detected = False
|
|
302
|
+
token = None
|
|
303
|
+
with self.engine.begin() as conn:
|
|
304
|
+
existing_grant = conn.execute(select(db.oauth_refresh_token.c.grant_id).where(
|
|
305
|
+
db.oauth_refresh_token.c.token_hash == _digest(refresh_token.token),
|
|
306
|
+
db.oauth_refresh_token.c.client_id == client.client_id,
|
|
307
|
+
)).scalar_one_or_none()
|
|
308
|
+
active_grant = conn.execute(select(db.oauth_refresh_token.c.grant_id).where(
|
|
309
|
+
db.oauth_refresh_token.c.token_hash == _digest(refresh_token.token),
|
|
310
|
+
db.oauth_refresh_token.c.client_id == client.client_id,
|
|
311
|
+
db.oauth_refresh_token.c.revoked_at.is_(None),
|
|
312
|
+
db.oauth_refresh_token.c.expires_at > _iso(_now()),
|
|
313
|
+
)).scalar_one_or_none()
|
|
314
|
+
result = conn.execute(
|
|
315
|
+
update(db.oauth_refresh_token)
|
|
316
|
+
.where(
|
|
317
|
+
db.oauth_refresh_token.c.token_hash == _digest(refresh_token.token),
|
|
318
|
+
db.oauth_refresh_token.c.client_id == client.client_id,
|
|
319
|
+
db.oauth_refresh_token.c.revoked_at.is_(None),
|
|
320
|
+
db.oauth_refresh_token.c.expires_at > _iso(_now()),
|
|
321
|
+
)
|
|
322
|
+
.values(revoked_at=_iso(_now()))
|
|
323
|
+
)
|
|
324
|
+
if active_grant is None and existing_grant:
|
|
325
|
+
now = _iso(_now())
|
|
326
|
+
conn.execute(update(db.oauth_access_token).where(
|
|
327
|
+
db.oauth_access_token.c.grant_id == existing_grant,
|
|
328
|
+
db.oauth_access_token.c.revoked_at.is_(None),
|
|
329
|
+
).values(revoked_at=now))
|
|
330
|
+
conn.execute(update(db.oauth_refresh_token).where(
|
|
331
|
+
db.oauth_refresh_token.c.grant_id == existing_grant,
|
|
332
|
+
db.oauth_refresh_token.c.revoked_at.is_(None),
|
|
333
|
+
).values(revoked_at=now))
|
|
334
|
+
replay_detected = True
|
|
335
|
+
elif result.rowcount == 1:
|
|
336
|
+
claims = json.loads(refresh_token.subject or "{}")
|
|
337
|
+
token = self._mint_pair(
|
|
338
|
+
conn,
|
|
339
|
+
client.client_id,
|
|
340
|
+
claims["user_id"],
|
|
341
|
+
claims["org_id"],
|
|
342
|
+
sorted(requested),
|
|
343
|
+
refresh_token.resource,
|
|
344
|
+
grant_id=claims["grant_id"],
|
|
345
|
+
)
|
|
346
|
+
if replay_detected or token is None:
|
|
347
|
+
raise TokenError("invalid_grant", "Refresh token is invalid or already used.")
|
|
348
|
+
return token
|
|
349
|
+
|
|
350
|
+
async def load_access_token(self, token: str):
|
|
351
|
+
# The bearer is looked up before any tenant is known (system context);
|
|
352
|
+
# a valid token then scopes the rest of the MCP request to its org.
|
|
353
|
+
with tenant_context.system(), self.engine.connect() as conn:
|
|
354
|
+
row = (
|
|
355
|
+
conn.execute(
|
|
356
|
+
select(db.oauth_access_token).where(
|
|
357
|
+
db.oauth_access_token.c.token_hash == _digest(token),
|
|
358
|
+
db.oauth_access_token.c.revoked_at.is_(None),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
.mappings()
|
|
362
|
+
.first()
|
|
363
|
+
)
|
|
364
|
+
if not row or row["expires_at"] <= _iso(_now()):
|
|
365
|
+
return None
|
|
366
|
+
tenant_context.bind(row["organization_id"])
|
|
367
|
+
subject = json.dumps({"user_id": row["user_id"], "org_id": row["organization_id"]})
|
|
368
|
+
return AccessToken(
|
|
369
|
+
token=token,
|
|
370
|
+
client_id=row["client_id"],
|
|
371
|
+
scopes=json.loads(row["scopes"]),
|
|
372
|
+
expires_at=int(_parse_dt(row["expires_at"]).timestamp()),
|
|
373
|
+
resource=row["resource"],
|
|
374
|
+
subject=subject,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
|
378
|
+
table = db.oauth_access_token if isinstance(token, AccessToken) else db.oauth_refresh_token
|
|
379
|
+
grant_id = None
|
|
380
|
+
with self.engine.begin() as conn:
|
|
381
|
+
grant_id = conn.execute(
|
|
382
|
+
select(table.c.grant_id).where(table.c.token_hash == _digest(token.token))
|
|
383
|
+
).scalar_one_or_none()
|
|
384
|
+
if grant_id:
|
|
385
|
+
for target in (db.oauth_access_token, db.oauth_refresh_token):
|
|
386
|
+
conn.execute(
|
|
387
|
+
update(target)
|
|
388
|
+
.where(target.c.grant_id == grant_id, target.c.revoked_at.is_(None))
|
|
389
|
+
.values(revoked_at=_iso(_now()))
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def _mint_pair(
|
|
393
|
+
self,
|
|
394
|
+
conn,
|
|
395
|
+
client_id: str,
|
|
396
|
+
user_id: str,
|
|
397
|
+
org_id: str,
|
|
398
|
+
scopes: list[str],
|
|
399
|
+
resource: str | None,
|
|
400
|
+
*,
|
|
401
|
+
grant_id: str | None = None,
|
|
402
|
+
) -> OAuthToken:
|
|
403
|
+
access, refresh = _secret(), _secret()
|
|
404
|
+
grant_id = grant_id or _secret()
|
|
405
|
+
access_expires, refresh_expires = (
|
|
406
|
+
_now() + timedelta(seconds=ACCESS_TTL),
|
|
407
|
+
_now() + timedelta(seconds=REFRESH_TTL),
|
|
408
|
+
)
|
|
409
|
+
common = dict(
|
|
410
|
+
client_id=client_id,
|
|
411
|
+
user_id=user_id,
|
|
412
|
+
organization_id=org_id,
|
|
413
|
+
scopes=json.dumps(scopes),
|
|
414
|
+
grant_id=grant_id,
|
|
415
|
+
resource=resource,
|
|
416
|
+
)
|
|
417
|
+
conn.execute(
|
|
418
|
+
db.oauth_access_token.insert().values(
|
|
419
|
+
token_hash=_digest(access), **common, expires_at=_iso(access_expires)
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
conn.execute(
|
|
423
|
+
db.oauth_refresh_token.insert().values(
|
|
424
|
+
token_hash=_digest(refresh), **common, expires_at=_iso(refresh_expires)
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
return OAuthToken(
|
|
428
|
+
access_token=access,
|
|
429
|
+
refresh_token=refresh,
|
|
430
|
+
expires_in=ACCESS_TTL,
|
|
431
|
+
scope=" ".join(scopes),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _resource_url(self) -> str:
|
|
435
|
+
return _public_origin(self.trusted_proxy) + "/mcp"
|
|
436
|
+
|
|
437
|
+
async def finish_consent(self, request: Request, pending_id: str, accepted: bool, csrf_token: str):
|
|
438
|
+
auth = request.scope.get("hoja_auth")
|
|
439
|
+
now = _iso(_now())
|
|
440
|
+
with self.engine.begin() as conn:
|
|
441
|
+
row = conn.execute(select(db.oauth_pending).where(
|
|
442
|
+
db.oauth_pending.c.pending_hash == _digest(pending_id),
|
|
443
|
+
db.oauth_pending.c.consumed_at.is_(None),
|
|
444
|
+
db.oauth_pending.c.expires_at > now,
|
|
445
|
+
)).mappings().first()
|
|
446
|
+
if not auth or not row or not secrets.compare_digest(csrf_token, row["csrf_token"]):
|
|
447
|
+
return JSONResponse({"error": "Authorization session expired; restart from the MCP client."}, status_code=400)
|
|
448
|
+
if row["user_id"] and (row["user_id"] != auth.get("user_id") or row["organization_id"] != auth.get("org_id")):
|
|
449
|
+
return JSONResponse({"error": "Authorization session expired; restart from the MCP client."}, status_code=400)
|
|
450
|
+
changed = conn.execute(update(db.oauth_pending).where(
|
|
451
|
+
db.oauth_pending.c.pending_hash == row["pending_hash"],
|
|
452
|
+
db.oauth_pending.c.consumed_at.is_(None), db.oauth_pending.c.expires_at > now,
|
|
453
|
+
).values(consumed_at=now, user_id=auth["user_id"], organization_id=auth["org_id"])).rowcount
|
|
454
|
+
if changed != 1:
|
|
455
|
+
return JSONResponse({"error": "Authorization request already used."}, status_code=400)
|
|
456
|
+
if not accepted:
|
|
457
|
+
return JSONResponse({"error": "Authorization was denied."}, status_code=403)
|
|
458
|
+
code = _secret()
|
|
459
|
+
conn.execute(db.oauth_authorization.insert().values(
|
|
460
|
+
code_hash=_digest(code), client_id=row["client_id"], user_id=auth["user_id"],
|
|
461
|
+
organization_id=auth["org_id"], scopes=row["scopes"],
|
|
462
|
+
code_challenge=row["code_challenge"], redirect_uri=row["redirect_uri"],
|
|
463
|
+
resource=row["resource"], expires_at=_iso(_now()+timedelta(seconds=CODE_TTL)), consumed_at=None,
|
|
464
|
+
))
|
|
465
|
+
query = {"code": code}
|
|
466
|
+
if row["state"]:
|
|
467
|
+
query["state"] = row["state"]
|
|
468
|
+
return RedirectResponse(row["redirect_uri"] + ("&" if "?" in row["redirect_uri"] else "?") + urlencode(query))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _parse_dt(value: str) -> datetime:
|
|
472
|
+
return datetime.fromisoformat(value)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _is_loopback_uri(uri: str) -> bool:
|
|
476
|
+
import ipaddress
|
|
477
|
+
from urllib.parse import urlparse
|
|
478
|
+
|
|
479
|
+
parsed = urlparse(uri)
|
|
480
|
+
if parsed.scheme != "http" or parsed.hostname is None:
|
|
481
|
+
return False
|
|
482
|
+
if parsed.hostname == "localhost":
|
|
483
|
+
return True
|
|
484
|
+
try:
|
|
485
|
+
return ipaddress.ip_address(parsed.hostname).is_loopback
|
|
486
|
+
except ValueError:
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _public_origin(trust_proxy: bool, public_host: str | None = None) -> str:
|
|
491
|
+
# Proxy headers are only honored when operator explicitly enables trust.
|
|
492
|
+
import os
|
|
493
|
+
|
|
494
|
+
if not trust_proxy:
|
|
495
|
+
raise RuntimeError("Hoja MCP OAuth requires explicit trusted proxy configuration.")
|
|
496
|
+
host = (public_host if public_host is not None else os.environ.get("HOJA_TRUSTED_PUBLIC_HOST", "")).strip()
|
|
497
|
+
scheme = os.environ.get("HOJA_TRUSTED_PUBLIC_SCHEME", "https").strip().lower()
|
|
498
|
+
if not host or "/" in host or "@" in host or scheme != "https":
|
|
499
|
+
raise RuntimeError("Trusted proxy public origin is not configured safely.")
|
|
500
|
+
return f"https://{host}"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_server(provider: HojaOAuthProvider, public_host: str | None = None) -> MCPServer:
|
|
504
|
+
from hoja import __version__
|
|
505
|
+
|
|
506
|
+
# Keep ordinary Hoja API/test startup independent of OAuth deployment config.
|
|
507
|
+
# OAuth metadata is only served when trusted proxy origin config is supplied.
|
|
508
|
+
public_origin = (
|
|
509
|
+
_public_origin(provider.trusted_proxy, public_host) if provider.trusted_proxy else "https://mcp.invalid"
|
|
510
|
+
)
|
|
511
|
+
issuer = public_origin + "/mcp"
|
|
512
|
+
server = MCPServer(
|
|
513
|
+
name="hoja",
|
|
514
|
+
instructions=MCP_INSTRUCTIONS,
|
|
515
|
+
version=__version__,
|
|
516
|
+
auth_server_provider=provider,
|
|
517
|
+
auth=AuthSettings(
|
|
518
|
+
issuer_url=issuer,
|
|
519
|
+
resource_server_url=issuer,
|
|
520
|
+
validate_token_resource=True,
|
|
521
|
+
required_scopes=[READ_SCOPE],
|
|
522
|
+
client_registration_options=ClientRegistrationOptions(
|
|
523
|
+
enabled=True, valid_scopes=ALL_SCOPES, default_scopes=[READ_SCOPE]
|
|
524
|
+
),
|
|
525
|
+
revocation_options=RevocationOptions(enabled=True),
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
530
|
+
|
|
531
|
+
from hoja_server.mcp_tools import build_tools
|
|
532
|
+
from hoja_server.resolvers import build_schema
|
|
533
|
+
|
|
534
|
+
def principal(write: bool) -> dict:
|
|
535
|
+
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
536
|
+
|
|
537
|
+
access = get_access_token()
|
|
538
|
+
if access is None or not access.subject:
|
|
539
|
+
raise ToolError("OAuth authentication is required. " + DENY_HINT)
|
|
540
|
+
granted = set(access.scopes)
|
|
541
|
+
if write and WRITE_SCOPE not in granted:
|
|
542
|
+
raise ToolError(
|
|
543
|
+
f"This connection is read-only: the {WRITE_SCOPE} scope was not granted, so nothing was changed. "
|
|
544
|
+
"Ask the user to reconnect the Hoja MCP with write access. " + DENY_HINT
|
|
545
|
+
)
|
|
546
|
+
if READ_SCOPE not in granted:
|
|
547
|
+
raise ToolError(f"OAuth scope {READ_SCOPE} is required. " + DENY_HINT)
|
|
548
|
+
try:
|
|
549
|
+
identity = json.loads(access.subject)
|
|
550
|
+
user_id, org_id = identity["user_id"], identity["org_id"]
|
|
551
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
552
|
+
raise ToolError("OAuth identity is invalid. " + DENY_HINT) from exc
|
|
553
|
+
if not provider.store.membership_for_profile(org_id, user_id):
|
|
554
|
+
raise ToolError("Active organization membership is required. " + DENY_HINT)
|
|
555
|
+
return {
|
|
556
|
+
"store": provider.store,
|
|
557
|
+
"writer": provider.writer,
|
|
558
|
+
"org_id": org_id,
|
|
559
|
+
"user_id": user_id,
|
|
560
|
+
"identity_id": None,
|
|
561
|
+
"browser_session": False,
|
|
562
|
+
# Read-only grants must not trigger resolver-side reconciliation writes.
|
|
563
|
+
"read_only": WRITE_SCOPE not in granted,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for tool in build_tools(build_schema(), principal):
|
|
567
|
+
server._tool_manager._tools[tool.name] = tool
|
|
568
|
+
|
|
569
|
+
@server.custom_route("/oauth/consent", methods=["GET", "POST"])
|
|
570
|
+
async def consent(request: Request):
|
|
571
|
+
pending_id = request.query_params.get("pending")
|
|
572
|
+
auth = request.scope.get("hoja_auth")
|
|
573
|
+
if not auth:
|
|
574
|
+
response = RedirectResponse(
|
|
575
|
+
"/auth/login?next=" + request.url.path + "%3Fpending%3D" + (pending_id or "")
|
|
576
|
+
)
|
|
577
|
+
return response
|
|
578
|
+
if request.method == "POST":
|
|
579
|
+
form = await request.form()
|
|
580
|
+
return await provider.finish_consent(
|
|
581
|
+
request,
|
|
582
|
+
pending_id or "",
|
|
583
|
+
form.get("decision") == "allow",
|
|
584
|
+
str(form.get("csrf_token", "")),
|
|
585
|
+
)
|
|
586
|
+
with provider.engine.connect() as conn:
|
|
587
|
+
pending = conn.execute(select(db.oauth_pending).where(
|
|
588
|
+
db.oauth_pending.c.pending_hash == _digest(pending_id or ""),
|
|
589
|
+
db.oauth_pending.c.consumed_at.is_(None), db.oauth_pending.c.expires_at > _iso(_now()),
|
|
590
|
+
)).mappings().first()
|
|
591
|
+
if pending and request.method == "GET":
|
|
592
|
+
with provider.engine.begin() as conn:
|
|
593
|
+
conn.execute(update(db.oauth_pending).where(
|
|
594
|
+
db.oauth_pending.c.pending_hash == pending["pending_hash"],
|
|
595
|
+
db.oauth_pending.c.consumed_at.is_(None),
|
|
596
|
+
db.oauth_pending.c.user_id.is_(None),
|
|
597
|
+
).values(user_id=auth["user_id"], organization_id=auth["org_id"]))
|
|
598
|
+
pending = conn.execute(select(db.oauth_pending).where(
|
|
599
|
+
db.oauth_pending.c.pending_hash == pending["pending_hash"]
|
|
600
|
+
)).mappings().first()
|
|
601
|
+
client = await provider.get_client(pending["client_id"]) if pending else None
|
|
602
|
+
if not pending or not client or pending["user_id"] != auth["user_id"] or pending["organization_id"] != auth["org_id"]:
|
|
603
|
+
return JSONResponse({"error": "Authorization request expired."}, status_code=400)
|
|
604
|
+
return HTMLResponse(
|
|
605
|
+
"<!doctype html><meta charset=utf-8><title>Authorize Hoja MCP</title>"
|
|
606
|
+
"<h1>Authorize MCP client</h1><p>Client: "
|
|
607
|
+
+ _escape(client.client_name or client.client_id)
|
|
608
|
+
+ "</p>"
|
|
609
|
+
"<p>Workspace: " + _escape(auth.get("org_name", auth["org_id"])) + "</p>"
|
|
610
|
+
"<p>Requested access: " + _escape(", ".join(pending["scopes"])) + "</p>"
|
|
611
|
+
"<form method=post><input type=hidden name=csrf_token value=\""
|
|
612
|
+
+ _escape(pending["csrf_token"])
|
|
613
|
+
+ "\"><button name=decision value=allow>Allow</button>"
|
|
614
|
+
"<button name=decision value=deny>Deny</button></form>"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return server
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _escape(value: str) -> str:
|
|
621
|
+
import html
|
|
622
|
+
|
|
623
|
+
return html.escape(value, quote=True)
|