
    (GjN                       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mZmZ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)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_session_providers)
AuditEvent	audit_log)ProviderErrorRefreshExpiredError)clear_sso_attempt_cookieread_session_cookiesread_sso_attempt_cookieset_sso_attempt_cookie)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     J/home/rurouni/.hermes/hermes-agent/hermes_cli/dashboard_auth/middleware.py	<genexpr>z"_path_is_public.<locals>.<genexpr>I   sL         	1$//&11         )r   anyr   )r   s   `r!   _path_is_publicr%   :   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_ipr1   O   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detailr2   	login_urli  status_code.  urlr?   ) hermes_cli.dashboard_auth.prefixr5   rB   r   _safe_next_targetr   r   r   )r&   r2   r5   r   
next_paramr    r=   
error_codes           r!   _unauth_responserG   V   s    , EDDDDD;D"7++J  ))F/9 	6++z+++ 
 w 
 555 " 	
 #( &	  
 
 
 	
 	s;;;;r#   Response | Nonec                t   | j         j        }|                    d          rdS t          |           r3ddlm} t          | d          }t          | ||                      |S t                      }t          |          dk    rdS ddlm} |d         } ||           }t          |           }dd	lm} | d
 ||j        d           }	|r|	 d| }	t          |	d          }ddlm}
 t#          | |
|           |           t%          t&          j        |j        dt+          |                      |S )u  Maybe auto-initiate the portal OAuth redirect on an unauth HTML load.

    Returns a 302 → ``/auth/login`` (the existing OAuth-initiation route)
    when ALL of the following hold, else ``None`` (caller falls back to the
    ordinary ``/login`` interstitial):

      * the request is an HTML document navigation, not an ``/api/*`` fetch
        (a fetch() would follow the 302 into the cross-origin OAuth dance
        opaquely — same reason ``_unauth_response`` never redirects APIs);
      * exactly ONE interactive provider is registered — with two or more we
        can't pick for the user, so the ``/login`` chooser must render; with
        zero there's nothing to redirect to;
      * the one-shot loop-guard marker is ABSENT. Its presence means we
        already bounced to the portal once and came back still
        unauthenticated (no portal session) — auto-redirecting again would
        ping-pong, so we fall through to ``/login`` and clear the marker.

    The portal ``/oauth/authorize`` auto-approves any current member of the
    dashboard's org and is a silent 302 when the user already holds a portal
    session, so for the common case (clicked a dashboard link while signed
    in to the portal) this removes the interstitial CLICK entirely. It
    removes a click, not a security check: the redirect lands on
    ``/auth/login`` which runs the unchanged PKCE auth-code flow.
    r6   Nr   r4   	no_cookier2   r       quotez/auth/login?provider=r(   safez&next=r@   rA   )detect_https)	use_httpsr    auto_ssoproviderr2   ip)rB   r   r   r   rC   r5   rG   r   r
   lenrD   urllib.parserO   namer   !hermes_cli.dashboard_auth.cookiesrR   r   r   r   LOGIN_STARTr1   )r&   r   r5   resp	providersrV   r    rE   rO   
auth_loginrR   s              r!   _auto_sso_responser`      s   2 ;Dw t w'' HHHHHH<<< .A.A'.J.JKKKK '((I
9~~tDDDDDD|H  ))F"7++J""""""PPx}21N1N1NPPJ 7"66*66

<<<D
 ?>>>>>W--f    g	    Kr#   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/apir6   ?r   rN   rP   )rB   r   r   r$   queryrY   rO   )r&   rf   targetrO   r   s       @r!   rD   rD      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           
     L  K   t          | j        j        dd          s ||            d{V S t          | j        dd          r ||            d{V S | j        j        }t          |          r ||            d{V S t          |           \  }}|s&|s$t          |           }||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        t;          |           ||            ||                      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token_authenticatedrJ   rK   )access_tokenz9dashboard-auth: provider %r unreachable during verify: %sprovider_unreachablerU   r<   zAuth provider z unreachablei  r>   refresh_tokenr   )rR   set_session_cookiesr4   )rm   rp   access_token_expires_inrS   r    )rV   user_idrW   no_provider_recognises)r2   rW   r7   )clear_session_cookiesrL   )!getattrappstaterB   r   r%   r   r`   rG   r
   verify_sessionr   _logwarningrZ   r   r   SESSION_VERIFY_FAILUREr1   r   _attempt_refreshsessionr[   rR   rq   rC   r5   rm   rp   _expires_in_secondsREFRESH_SUCCESSrs   ru   )r&   rh   r   at_rtautor~   unreachable_providerrV   e	refreshednew_sessionrefreshing_providerresponserR   rq   r5   ru   s                     r!   gated_auth_middlewarer      s      7;$ou== (Yw''''''''' w}3U;; (Yw''''''''';Dt (Yw'''''''''"7++GB =c = "'**K<<<<  G	 +  ,0.0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   C**
E4AEE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maxr   
expires_at)r~   r   s     r!   r   r     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.
    Nro   refresh_expiredrU   z:dashboard-auth: provider %r unreachable during refresh: %srn   )r
   refresh_sessionr   r   r   REFRESH_FAILURErZ   r1   r   rz   r{   )r&   rp   rV   r   r   s        r!   r}   r}     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   r2   r   r   r	   )r&   r   r   rH   )r&   r   rh   ri   r   r	   )r   r   )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   r[   r   r   r   r   &hermes_cli.dashboard_auth.public_pathsr   	getLogger__name__rz   r   __annotations__r%   r1   rG   r`   rD   r   r   r}    r#   r!   <module>r      s      # " " " " "  & & & & & & & &       F F F F F F F F F F < < < < < < A A A A A A A A M M M M M M M M            D C C C C Cw""*        *9 9 9 93< 3< 3< 3<lF F F FR"" "" "" ""JY$ Y$ Y$ Y$x
? 
? 
? 
?) ) ) ) ) )r#   