
    $jG=                        U d Z ddlmZ ddlZddlmZmZ ddlmZ ddl	m
Z
mZmZ ddlmZ ddlmZmZ dd	lmZmZ dd
lmZ ddlmZ  ej        e          ZdZded<   d!dZd"dZd#dZ d"dZ!d$dZ"d%dZ#d&d Z$dS )'a  Auth-gate middleware for the dashboard.

Engaged when ``app.state.auth_required is True``. The gate's job:

  1. Allow a small set of routes through unauthenticated (login page,
     ``/auth/*`` OAuth round trip, ``/api/auth/providers``, static
     assets).
  2. For everything else, demand a valid session cookie and attach the
     verified :class:`Session` to ``request.state.session``.
  3. On HTML routes, redirect missing/invalid cookies to ``/login``.
     On ``/api/*`` routes, return 401 JSON.

The middleware is a no-op when ``auth_required`` is False (loopback
mode); the legacy ``_SESSION_TOKEN`` ``auth_middleware`` handles those
binds.
    )annotationsN)	AwaitableCallable)Request)JSONResponseRedirectResponseResponse)list_providers)
AuditEvent	audit_log)ProviderErrorRefreshExpiredError)read_session_cookies)PUBLIC_API_PATHS)z/auth/loginz/auth/callbackz/auth/password-loginz/auth/logout/loginz/api/auth/providersz/assets/z/favicon.icoz/ds-assets/z/fonts/z/fonts-terminal/ztuple[str, ...]_GATE_PUBLIC_PREFIXESpathstrreturnboolc                Z      t           v rdS t           fdt          D                       S )u  True if ``path`` bypasses the OAuth auth gate.

    Two sources of public-ness:

    * :data:`PUBLIC_API_PATHS` — the shared ``/api/*`` allowlist that
      the legacy ``_SESSION_TOKEN`` middleware also honours. Matched
      exactly (no prefix expansion) so adding ``/api/status`` doesn't
      accidentally expose ``/api/status/secret-extension``.
    * :data:`_GATE_PUBLIC_PREFIXES` — auth-bootstrap routes and static
      mounts. Prefix-matched so ``/assets/foo.css`` lights up via
      ``/assets/``.
    Tc              3  N   K   | ]}|k    p                     |          V   d S N
startswith).0prefixr   s     C/usr/local/lib/hermes-agent/hermes_cli/dashboard_auth/middleware.py	<genexpr>z"_path_is_public.<locals>.<genexpr>D   sL         	1$//&11         )r   anyr   )r   s   `r   _path_is_publicr"   5   sN     t    +     r    requestr   c                    | j                             dd          }|r-|                    d          d                                         S | j        r| j        j        ndS )Nzx-forwarded-for ,r   )headersgetsplitstripclienthost)r#   fwds     r   
_client_ipr.   J   sZ    
/

/
4
4C
 )yy~~a &&(((").87>b8r    reasonr	   c                  ddl m} | j        j        }t	          |           } ||           }|r| d| n| d}|                    d          r |dk    rdnd}t          |d	||d
d          S t          |d          S )u  API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login.

    The JSON envelope carries a ``login_url`` field with a ``next=`` query
    string so the SPA's global 401 handler can drop the user back where
    they were after re-auth. The contract is intentionally simple so any
    fetch-wrapper can implement the redirect without parsing details:

        if response.status === 401 && body.error in ("unauthenticated",
                                                       "session_expired"):
            window.location.assign(body.login_url);

    HTML redirects also carry the ``next=`` query string so direct
    navigation to ``/sessions`` (etc.) without a cookie comes back to
    ``/sessions`` after login.

    Under a reverse proxy with ``X-Forwarded-Prefix: /hermes``, the
    ``login_url`` is prefixed (``/hermes/login?next=...``) so the
    browser's window.location.assign / Location: follow lands on the
    proxied login page rather than the bare ``/login`` (which the
    proxy doesn't route to the dashboard).
    r   prefix_from_requestz/login?next=r   /api/invalid_or_expired_sessionsession_expiredunauthenticatedUnauthorized)errordetailr/   	login_urli  status_codei.  )urlr<   ) hermes_cli.dashboard_auth.prefixr2   r=   r   _safe_next_targetr   r   r   )r#   r/   r2   r   
next_paramr   r:   
error_codes           r   _unauth_responserB   Q   s    , EDDDDD;D"7++J  ))F/9 	6++z+++ 
 w 
 555 " 	
 #( &	  
 
 
 	
 	s;;;;r    c                @   | j         j        r*                    d          r                    d          rdS t          fddD                       rdS dk    s                    d          rdS | j         j        }|r d| n}d	d
lm}  ||d          S )uO  Build the URL-encoded ``next`` query value, or empty string.

    Only same-origin relative paths are accepted; absolute URLs or
    ``//evil.com`` open-redirect attempts are silently dropped. The empty
    string return means the caller produces a bare ``/login`` URL — fine,
    user lands at the dashboard root after re-auth.
    /z//r%   c              3  N   K   | ]}|k    p                     |          V   d S r   r   )r   pr   s     r   r   z$_safe_next_target.<locals>.<genexpr>   sL         		'T__Q''     r    )r   z/auth/z
/api/auth/z/apir3   ?r   )quote)safe)r=   r   r   r!   queryurllib.parserH   )r#   rJ   targetrH   r   s       @r   r?   r?      s     ;D  ts++ tt/D/D r
    3      r v~~11~rKE"'1uTF""""""5b!!!!r    	call_next(Callable[[Request], Awaitable[Response]]c           
       K   t          | j        j        dd          s ||            d{V S | j        j        }t          |          r ||            d{V S t          |           \  }}|s|st          | d          S d}|rd}t                      D ]}	 |	                    |          }np# t          $ rc}t                              d|j        |           t          t          j        |j        dt#          |           	           ||j        }Y d}~d}~ww xY w| n||t%          d
d|did          S |t'          | |          }	|	|	\  }
}|
| j        _         ||            d{V }ddlm}m} ddlm}  |||
j        |
j        t9          |
           ||            ||                      t          t          j        ||
j        t#          |                      |S t          t          j        dt#          |                      t          | d          }ddlm} ddlm}  || ||                      |S || j        _         ||            d{V S )zEngaged only when ``app.state.auth_required is True``.

    No-op pass-through in loopback mode so the legacy auth_middleware can
    handle those binds via ``_SESSION_TOKEN``.
    auth_requiredFN	no_cookie)r/   )access_tokenz9dashboard-auth: provider %r unreachable during verify: %sprovider_unreachableproviderr/   ipr9   zAuth provider z unreachablei  r;   refresh_tokenr   )detect_httpsset_session_cookiesr1   )rR   rX   access_token_expires_in	use_httpsr   )rU   user_idrV   no_provider_recognises)r/   rV   r4   )clear_session_cookies)r   ) getattrappstater=   r   r"   r   rB   r
   verify_sessionr   _logwarningnamer   r   SESSION_VERIFY_FAILUREr.   r   _attempt_refreshsession!hermes_cli.dashboard_auth.cookiesrY   rZ   r>   r2   rR   rX   _expires_in_secondsREFRESH_SUCCESSr]   r_   )r#   rM   r   at_rtri   unreachable_providerrU   e	refreshednew_sessionrefreshing_providerresponserY   rZ   r2   r_   s                    r   gated_auth_middlewareru      s      7;$ou== (Yw''''''''';Dt (Yw'''''''''"7++GB =c =  <<<<  G	 +  ,0&(( 	 	H"11r1BB    OM1   5%]1!'**	    (/+3=( " #?3?  P,@PPPQ   
  %WC@@@	 /8,K,$/GM!&Yw////////H        MLLLLL(5)7(;K(H(H&,w//**733    *,#+g&&	    O-+'""	
 	
 	
 	

 $G4PQQQ 	LKKKKKHHHHHHh/B/B7/K/KLLLL#GM7#########s   B00
D:ADDintc                    ddl }t          dt          | j                  t          |                                           z
            S )a%  Seconds until the access token's ``exp``, floored at 60.

    Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
    cookie's Max-Age tracks the token lifetime even on a slightly skewed
    clock. ``time`` imported locally to keep the module's import surface
    minimal.
    r   N<   )timemaxrv   
expires_at)ri   ry   s     r   rk   rk   9  s=     KKKr3w)**S-=-==>>>r    c          
        |sdS t                      D ]}	 |                    |          }n# t          $ r4 t          t          j        |j        dt          |                      Y  dS t          $ r\}t          
                    d|j        |           t          t          j        |j        dt          |                      Y d}~ dS d}~ww xY w|||j        fc S dS )uN  Try to rotate an expired session via the refresh token.

    Returns ``(new_session, provider_name)`` on success, or ``None`` if
    there's no RT or every provider's ``refresh_session`` failed with
    ``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login).

    A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
    here — re-raising would 500 the request; instead we log and return None so
    the caller forces a clean re-login, which is the safer UX than a hard
    error on a transient network blip during the narrow refresh window.
    NrW   refresh_expiredrT   z:dashboard-auth: provider %r unreachable during refresh: %srS   )r
   refresh_sessionr   r   r   REFRESH_FAILURErf   r.   r   rd   re   )r#   rX   rU   rr   rp   s        r   rh   rh   F  s9     t"$$ . .	"222OOKK" 		 		 		 *!(g&&	    444 	 	 	LLLq   *!-g&&	    444444	 "---- #4s   -9C*	C3AC

C)r   r   r   r   )r#   r   r   r   )r#   r   r/   r   r   r	   )r#   r   rM   rN   r   r	   )r   rv   )r#   r   )%__doc__
__future__r   loggingtypingr   r   fastapir   fastapi.responsesr   r   r	   hermes_cli.dashboard_authr
   hermes_cli.dashboard_auth.auditr   r   hermes_cli.dashboard_auth.baser   r   rj   r   &hermes_cli.dashboard_auth.public_pathsr   	getLogger__name__rd   r   __annotations__r"   r.   rB   r?   ru   rk   rh    r    r   <module>r      s      # " " " " "  & & & & & & & &       F F F F F F F F F F 4 4 4 4 4 4 A A A A A A A A M M M M M M M M B B B B B B C C C C C Cw""*        *9 9 9 93< 3< 3< 3<l"" "" "" ""JJ$ J$ J$ J$Z
? 
? 
? 
?) ) ) ) ) )r    