Skip to content

Commit 51f9869

Browse files
hrantzschJenkins
authored andcommitted
Store and validate requested oauth scopes
/authorize now expects client to send valid scopes and mints the selected scopes into tokens. The scope vocabulary and its wire format live in cmk.gui.scopes, free of Checkmk dependencies. Resolving scopes to permissions is a separate concern and comes later. The MCP requests read and write by default. Existing tokens in from before this commit carry the old "mcp" scope and will need to re-authenticate. Change-Id: I03a31b20a14e8296df30071938bcf6be84caed1e
1 parent a8cf611 commit 51f9869

18 files changed

Lines changed: 539 additions & 57 deletions

cmk/gui/BUILD

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,17 @@ py_library(
497497
],
498498
)
499499

500+
py_library(
501+
name = "scopes",
502+
srcs = ["scopes.py"],
503+
imports = ["../.."],
504+
visibility = [
505+
"//cmk:__subpackages__",
506+
"//non-free/packages:__subpackages__",
507+
"//tests:__subpackages__",
508+
],
509+
)
510+
500511
py_library(
501512
name = "auth_core",
502513
srcs = [

cmk/gui/OWNERS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ set noparent # don't inherit owners information from parent folders
33
per-file auth.py=file:/component_owners/user_management_and_authentication/OWNERS_DEFINITION
44
per-file auth.py=set noparent
55

6+
per-file authorization.py=file:/component_owners/user_management_and_authentication/OWNERS_DEFINITION
7+
per-file authorization.py=set noparent
8+
69
per-file login.py=file:/component_owners/user_management_and_authentication/OWNERS_DEFINITION
710
per-file login.py=set noparent
811

cmk/gui/oauth/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ py_library(
1616
requirement("redis"),
1717
"//cmk/gui:core_toolkit",
1818
"//cmk/gui:page_toolkit",
19+
"//cmk/gui:scopes",
1920
"//cmk/gui/utils:csrf_token",
2021
"//cmk/gui/wato:base",
2122
"//cmk/gui/watolib",

cmk/gui/oauth/pages/_authorize.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
from cmk.gui.oauth import client_store
2020
from cmk.gui.oauth.store._auth_code_store import AuthCodeRecord, AuthCodeStore
2121
from cmk.gui.pages import Page, PageContext, PageResult
22+
from cmk.gui.scopes import (
23+
DEFAULT_SCOPE,
24+
format_scopes,
25+
InvalidScopeError,
26+
parse_scopes,
27+
ScopeId,
28+
)
2229
from cmk.gui.utils.csrf_token import check_csrf_token
2330
from cmk.gui.utils.security_log_events import OAuthAuthorizationFailureEvent
2431
from cmk.gui.utils.transaction_manager import transactions
@@ -35,13 +42,17 @@ class OAuthAuthorizePage(Page):
3542
code. Returns 404 while no OAuth-consuming feature is enabled for the
3643
site (the enabled predicate is injected at registration).
3744
45+
This is where the granted scope is decided: the requested scope is
46+
validated and normalized (see cmk.gui.scopes), shown to the user,
47+
and bound to the code in that form, so the client's raw scope string never
48+
reaches the token. There is no per-scope selection UI; approving grants
49+
what was asked for.
50+
3851
Codes minted on approval are persisted PKCE-bound via AuthCodeStore; the
3952
token endpoint later redeems them single-use. Validates client_id against
4053
the registered-client store (see cmk.gui.oauth.client_store()) and requires
4154
redirect_uri to exactly match one of that client's registered
42-
redirect_uris. _token.py does not yet validate that a code was issued to
43-
the client redeeming it -- that's separate follow-up work. Rejected
44-
requests are logged as security events (see
55+
redirect_uris. Rejected requests are logged as security events (see
4556
OAuthAuthorizationFailureEvent).
4657
"""
4758

@@ -106,18 +117,35 @@ def page(self, ctx: PageContext) -> PageResult:
106117
self._error_redirect(ctx, redirect_uri, "invalid_request")
107118
return None
108119

120+
if len(request.values.getlist("scope")) > 1:
121+
# RFC 6749 section 3.1 forbids repeating a request parameter, and
122+
# with duplicates there is no answer to what the user is approving.
123+
self._log_authorization_failure("repeated scope parameter")
124+
self._error_redirect(ctx, redirect_uri, "invalid_request")
125+
return None
126+
127+
raw_scope = request.var("scope", "").strip()
128+
try:
129+
# RFC 6749 section 3.3 leaves what an omitted scope means to us.
130+
granted_scopes = parse_scopes(raw_scope) if raw_scope else DEFAULT_SCOPE
131+
except InvalidScopeError as exc:
132+
# RFC 6749 section 4.1.2.1. Rejected rather than downscoped.
133+
self._log_authorization_failure(f"unknown scope: {exc}")
134+
self._error_redirect(ctx, redirect_uri, "invalid_scope")
135+
return None
136+
109137
# received authorization form OK
110138
if request.request_method == "POST":
111139
check_csrf_token()
112140
if transactions.check_transaction():
113141
if request.var("_deny") is not None:
114142
self._error_redirect(ctx, redirect_uri, "access_denied")
115143
return None
116-
self._issue_code(ctx, redirect_uri, client_id, code_challenge)
144+
self._issue_code(ctx, redirect_uri, client_id, code_challenge, granted_scopes)
117145
return None
118146

119147
# show concent page
120-
self._show_consent_page(ctx, redirect_uri)
148+
self._show_consent_page(ctx, redirect_uri, granted_scopes)
121149
return None
122150

123151
def _open_login_frame(self, ctx: PageContext, title: str) -> None:
@@ -150,7 +178,12 @@ def _close_login_frame(self) -> None:
150178
html.footer()
151179

152180
def _issue_code(
153-
self, ctx: PageContext, redirect_uri: str, client_id: str, code_challenge: str
181+
self,
182+
ctx: PageContext,
183+
redirect_uri: str,
184+
client_id: str,
185+
code_challenge: str,
186+
granted_scopes: frozenset[ScopeId],
154187
) -> None:
155188
# The bound user is the server-side session user; the page registry
156189
# guarantees an authenticated session before this code runs.
@@ -160,7 +193,9 @@ def _issue_code(
160193
user_id=user.id,
161194
client_id=client_id,
162195
redirect_uri=redirect_uri,
163-
scope=request.var("scope"),
196+
# The normalized grant the consent page showed, not the client's
197+
# raw scope string.
198+
scope=format_scopes(granted_scopes),
164199
resource=request.var("resource"),
165200
code_challenge=code_challenge,
166201
)
@@ -220,7 +255,9 @@ def _show_redirect_page(
220255
html.a(_("Click here if you are not redirected automatically."), href=target_url)
221256
self._close_login_frame()
222257

223-
def _show_consent_page(self, ctx: PageContext, redirect_uri: str) -> None:
258+
def _show_consent_page(
259+
self, ctx: PageContext, redirect_uri: str, granted_scopes: frozenset[ScopeId]
260+
) -> None:
224261
client_id = request.var("client_id")
225262

226263
self._open_login_frame(ctx, _("Authorize access"))
@@ -232,6 +269,16 @@ def _show_consent_page(self, ctx: PageContext, redirect_uri: str) -> None:
232269
_('The application "%(client_id)s" is requesting access to this Checkmk site.')
233270
% {"client_id": client_id}
234271
)
272+
descriptions = {
273+
ScopeId.READ: _("read data"),
274+
ScopeId.WRITE: _("change data and configuration"),
275+
}
276+
html.p(
277+
_("It is requesting permission to: %(grants)s.")
278+
# ScopeId order, so a given grant always reads the same way.
279+
% {"grants": ", ".join(descriptions[s] for s in ScopeId if s in granted_scopes)}
280+
)
281+
html.p(_("Your own user permissions still apply."))
235282
html.p(_("Redirect target: %(redirect_uri)s") % {"redirect_uri": redirect_uri})
236283
# Explicit action: this page is also reachable via the external OAuth
237284
# issuer alias (/oauth-<site>/authorize, see system_apache.py), where

cmk/gui/oauth/pages/_models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from pydantic import BaseModel, Field, field_validator
1010

11+
from cmk.gui.scopes import SUPPORTED_SCOPES
12+
1113

1214
class OAuthAuthorizationServerMetadata(BaseModel):
1315
"""RFC 8414 authorization server metadata document.
@@ -29,6 +31,7 @@ class OAuthAuthorizationServerMetadata(BaseModel):
2931
grant_types_supported: list[str] = ["authorization_code"]
3032
token_endpoint_auth_methods_supported: list[str] = ["none"]
3133
code_challenge_methods_supported: list[str] = ["S256"]
34+
scopes_supported: list[str] = list(SUPPORTED_SCOPES)
3235

3336

3437
class OAuthClientRegistrationRequest(BaseModel):
@@ -70,10 +73,16 @@ class OAuthClientRegistrationErrorResponse(BaseModel):
7073

7174

7275
class OAuthTokenResponse(BaseModel):
73-
"""RFC 6749 section 5.1 access token response."""
76+
"""RFC 6749 section 5.1 access token response.
77+
78+
scope is always sent, not only when it differs from the request as section
79+
5.1 requires at a minimum: normalization means it usually does differ, and
80+
no client should have to infer that.
81+
"""
7482

7583
access_token: str
7684
token_type: str = "Bearer"
85+
scope: str
7786

7887

7988
class OAuthTokenErrorResponse(BaseModel):

cmk/gui/oauth/pages/_token.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from cmk.gui.oauth.store._auth_code_store import AuthCodeStore
2121
from cmk.gui.oauth.store.token_store import UnknownClientError
2222
from cmk.gui.pages import Page, PageContext, PageResult
23+
from cmk.gui.scopes import format_scopes, parse_scopes
2324
from cmk.gui.utils.security_log_events import OAuthTokenFailureEvent
2425
from cmk.utils.security_event import log_security_event
2526

@@ -78,8 +79,11 @@ class OAuthTokenPage(Page):
7879
single-use against the store the authorize endpoint fills, and every
7980
binding of the redeemed record is enforced: the PKCE S256 challenge, the
8081
client_id (which must still be registered), and redirect_uri/resource if
81-
sent. A scope parameter is
82-
ignored; the eventual token's user and scope come only from the record.
82+
sent. A scope parameter on the token request is ignored; the token's user
83+
and scope come only from the record, so the scope is the one the user
84+
approved rather than one the client picks at redemption. It is echoed back
85+
in the response body, so a client that asked for more than it got finds out
86+
here instead of on its first rejected write.
8387
Rejections follow the RFC 6749 section 5.2 error format. The returned
8488
access token is a real, store-backed token issued for the record's user
8589
(see cmk.gui.oauth.token_store).
@@ -189,12 +193,16 @@ def page(self, ctx: PageContext) -> PageResult:
189193
_error("invalid_grant")
190194
return None
191195

196+
# We assume that parse_scopes will be fine here, as we serialized the previously parsed
197+
# scopes into the AuthCodeStore ourselves. If it still goes wrong it's not a user error.
198+
granted_scopes = parse_scopes(record.scope)
199+
192200
try:
193201
access_token = token_store().issue_token(
194202
UserId(record.user_id),
195203
expires_at=datetime.now(UTC) + _ACCESS_TOKEN_TTL,
196204
resource=record.resource,
197-
scope=record.scope,
205+
scope=granted_scopes,
198206
client_id=record.client_id,
199207
)
200208
except UnknownClientError:
@@ -205,5 +213,9 @@ def page(self, ctx: PageContext) -> PageResult:
205213
return None
206214

207215
response.set_content_type("application/json")
208-
response.set_data(OAuthTokenResponse(access_token=access_token).model_dump_json())
216+
response.set_data(
217+
OAuthTokenResponse(
218+
access_token=access_token, scope=format_scopes(granted_scopes)
219+
).model_dump_json()
220+
)
209221
return None

cmk/gui/oauth/store/_auth_code_store.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class AuthCodeRecord(BaseModel):
2626
user_id: str
2727
client_id: str
2828
redirect_uri: str
29-
scope: str | None
29+
scope: str
3030
resource: str | None
3131
code_challenge: str
3232

cmk/gui/oauth/store/token_store.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from datetime import datetime, UTC
1212

1313
from cmk.ccc.user import UserId
14+
from cmk.gui.log import logger
1415
from cmk.gui.oauth.store.backend import Backend
16+
from cmk.gui.scopes import format_scopes, InvalidScopeError, parse_scopes, ScopeId
1517

1618

1719
class UnknownClientError(LookupError):
@@ -29,7 +31,7 @@ class TokenRecord:
2931
issued_at: datetime
3032
expires_at: datetime
3133
resource: str | None
32-
scope: str | None
34+
scope: frozenset[ScopeId]
3335
client_id: str
3436

3537
def is_valid(self, *, at: datetime | None = None) -> bool:
@@ -65,13 +67,15 @@ def issue_token(
6567
*,
6668
expires_at: datetime,
6769
resource: str | None,
68-
scope: str | None,
70+
scope: frozenset[ScopeId],
6971
client_id: str,
7072
) -> str:
7173
if not user_id:
7274
raise ValueError("user_id must not be empty")
7375
if not client_id:
7476
raise ValueError("client_id must not be empty")
77+
if not scope:
78+
raise ValueError("scope must not be empty")
7579

7680
issued_at_timestamp = int(_utc_now().timestamp())
7781
expires_at_timestamp = _to_timestamp(expires_at)
@@ -93,7 +97,7 @@ def issue_token(
9397
issued_at_timestamp,
9498
expires_at_timestamp,
9599
resource,
96-
scope,
100+
format_scopes(scope),
97101
client_id,
98102
),
99103
)
@@ -133,16 +137,26 @@ def list_by_user(self, user_id: UserId) -> list[TokenRecord]:
133137
""",
134138
(user_id,),
135139
).fetchall()
136-
return [_row_to_record(row) for row in rows]
140+
return [record for row in rows if (record := _row_to_record(row)) is not None]
137141

138142

139-
def _row_to_record(row: sqlite3.Row) -> TokenRecord:
143+
def _row_to_record(row: sqlite3.Row) -> TokenRecord | None:
144+
"""The row as a record, or None if its scope is not one we could have written."""
145+
try:
146+
scope = parse_scopes(row["scope"] or "")
147+
except InvalidScopeError:
148+
logger.warning(
149+
"Refusing OAuth access token of user %(user_id)s: stored scope %(scope)r is not usable",
150+
{"user_id": row["user_id"], "scope": row["scope"]},
151+
)
152+
return None
153+
140154
return TokenRecord(
141155
user_id=UserId(row["user_id"]),
142156
issued_at=datetime.fromtimestamp(row["issued_at"], tz=UTC),
143157
expires_at=datetime.fromtimestamp(row["expires_at"], tz=UTC),
144158
resource=row["resource"],
145-
scope=row["scope"],
159+
scope=scope,
146160
client_id=row["client_id"],
147161
)
148162

0 commit comments

Comments
 (0)