1919from cmk .gui .oauth import client_store
2020from cmk .gui .oauth .store ._auth_code_store import AuthCodeRecord , AuthCodeStore
2121from 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+ )
2229from cmk .gui .utils .csrf_token import check_csrf_token
2330from cmk .gui .utils .security_log_events import OAuthAuthorizationFailureEvent
2431from 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
0 commit comments