
    JjZ                       U d Z ddlmZ ddlZddlZddlZddlmZmZ ddl	m
Z
mZmZmZ ddlmZmZmZ ddlmZmZmZ ddlmZ dd	lmZmZmZ dd
lmZmZ ddlm Z m!Z!m"Z" ddl#m$Z$m%Z%m&Z&m'Z'm(Z(m)Z)m*Z*m+Z+ ddl,m-Z-  ej.        e/          Z0 e            Z1dEdZ2dEdZ3dEdZ4e15                    dd          dFd            Z6e15                    dd          dGd            Z7e15                    dd          dHdId#            Z8e15                    d$d%          	 	 	 	 dJdKd*            Z9dLd,Z:d-Z;d.Z< ee          Z=d/e>d0<    ej?                    Z@dMd3ZAdNd5ZB G d6 d7e          ZCe1D                    d8d9          dOd;            ZEe1D                    d<d=          dPd>            ZFe15                    d?d@          dPdA            ZGe1D                    dBdC          dPdD            ZHdS )Qu  HTTP routes for the dashboard-auth OAuth round trip.

Mounted at root (no prefix) by ``web_server.py``. The router does not
auto-gate; gating is performed by ``gated_auth_middleware``, which
allowlists everything under ``/auth/*`` and ``/api/auth/providers``.

The routes:

  GET  /login              → server-rendered login page
  GET  /auth/login?provider=N → 302 to IDP, sets PKCE cookie
  GET  /auth/callback?code,state → completes login, sets session cookies
  POST /auth/logout        → clears cookies, best-effort revoke
  GET  /api/auth/providers → list registered providers (login bootstrap)
  GET  /api/auth/me        → current Session as JSON (auth-required)
    )annotationsN)defaultdictdeque)AnyDequeDictTuple)	APIRouterHTTPExceptionRequest)HTMLResponseJSONResponseRedirectResponse)	BaseModel)get_providerlist_providerslist_session_providers)
AuditEvent	audit_log)InvalidCodeErrorInvalidCredentialsErrorProviderError)clear_pkce_cookieclear_session_cookiesclear_sso_attempt_cookiedetect_httpsread_pkce_cookieread_session_cookiesset_pkce_cookieset_session_cookies)render_login_htmlrequestr   returnstrc                   ddl m}m} ddlm}m}  |            }|r| dS t          |                     d                    } ||           }|s|S  ||          } ||                    | |j	                             S )u  Reconstruct the absolute callback URL the IDP redirects back to.

    Three resolution tiers:

      1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var or
         ``dashboard.public_url`` in config.yaml — when set, this is
         the complete authority (scheme + host + optional path prefix)
         and we append ``/auth/callback`` verbatim. ``X-Forwarded-Prefix``
         is IGNORED on this code path because the operator has declared
         the public URL — we no longer need to guess from proxy headers,
         and stacking the prefix on top would double-prefix the common
         case where the prefix is already baked into ``public_url``.
         Relief valve for deploys behind reverse proxies whose forwarded
         headers aren't reliable.

      2. ``X-Forwarded-Prefix: /hermes`` (Mission Control deploys) — we
         prepend the prefix to the path FastAPI's ``url_for`` produces
         (it doesn't natively honour this header — it isn't part of the
         Starlette/uvicorn proxy_headers set).

      3. Bare ``request.url_for("auth_callback")`` — under uvicorn's
         ``proxy_headers=True`` this picks up the public https URL from
         ``X-Forwarded-Host`` plus ``X-Forwarded-Proto``. Fly.io's
         default path.
    r   )urlparse
urlunparse)prefix_from_requestresolve_public_url/auth/callbackauth_callback)path)
urllib.parser&   r'    hermes_cli.dashboard_auth.prefixr(   r)   r$   url_for_replacer,   )	r"   r&   r'   r(   r)   
public_urlbaseprefixparseds	            F/home/rurouni/.hermes/hermes-agent/hermes_cli/dashboard_auth/routes.py_redirect_urir6   8   s    4 21111111        $#%%J -
 ,,,, w//00D  ))F Xd^^F:foof+Cfk+C+CoDDEEE    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     r5   
_client_iprB   l   sZ    
/

/
4
4C
 )yy~~a &&(((").87>b8r7   c                $    ddl m}  ||           S )aA  Resolve the X-Forwarded-Prefix header for the active request.

    Local indirection so the routes pass a consistent value to the
    cookie helpers (cookie name + Path attribute) and the gate's
    redirect builders (login_url construction). See
    ``hermes_cli.dashboard_auth.prefix`` for the normalisation rules.
    r   )r(   )r.   r(   )r"   r(   s     r5   _prefixrD   s   s(     EDDDDDw'''r7   /login
login_page)namer   c                   K   t          | j                            dd                    }t          t	          |          ddi          S )Nnextr9   )	next_pathzCache-Controlz#no-store, no-cache, must-revalidate)r;   )_validate_post_login_targetquery_paramsr<   r   r!   )r"   rJ   s     r5   rF   rF      s]       ,  ,, I I... "GH   r7   z/api/auth/providersauth_providersr   c                 h   K   t                      } | st          ddid          S dd | D             iS )Ndetailzno auth providers registered  )status_code	providersc                f    g | ].}|j         |j        t          t          |d d                    d/S )supports_passwordF)rG   display_namerT   )rG   rU   boolgetattr).0ps     r5   
<listcomp>z&api_auth_providers.<locals>.<listcomp>   sX     	
 	
 	
   !%)A2E::& & 	
 	
 	
r7   )r   r   )rR   s    r5   api_auth_providersr[      sk       '((I 
56
 
 
 	

 	 	
 	
 	
 	
 	
 r7   z/auth/login
auth_loginr9   providerrI   c           	       K   t          |          }|t          dd|          t          |dd          st          dd|          	 |                    t	          |                     }nP# t
          $ rC}t          t          j        |dt          |           	           t          d
d|           d }~ww xY wt          t          j
        |t          |                      t          |j        d          }|j                            dd          }d|vr|rd| d| nd| }t          |          }|rddlm}	 | d |	|d           }t%          ||t'          |           t)          |                      |S )N  zUnknown provider: rQ   rO   supports_sessionTz-Provider does not support interactive login: )redirect_uriprovider_unreachabler]   reasoniprP   Provider unreachable: )r]   rf   .  urlrQ   hermes_session_pkcer9   z	provider=;r   )quotez;next=)safe)payload	use_httpsr3   )r   r   rW   start_loginr6   r   r   r   LOGIN_FAILURErB   LOGIN_STARTr   redirect_urlcookie_payloadr<   rK   r-   rm   r   r   rD   )
r"   r]   rI   rY   lseresppkce	safe_nextrm   s
             r5   r\   r\      s$     XAy444
 
 
 	
 1($// 
O8OO
 
 
 	


]]g(>(>]?? 

 

 

$)'""		
 	
 	
 	
 /A//
 
 
 	


 g    SAAAD   !6;;D$04P,8,,d,,,:Ph:P:P ,D11I :&&&&&&99eeIB77799dl7&;&;w    Ks   #A2 2
B?<>B::B?r*   r+   codestateerrorerror_descriptionc           
       K   t          |           }|s:t          t          j        dt	          |                      t          dd          t          d |                    d          D                       }|                    dd	          }|                    d
d	          }|                    dd	          }	|                    dd	          }
t          |          }|t          dd|          |rCt          t          j        |d|t	          |                      t          dd| d| d          |r||k    r;t          t          j        |dt	          |                      t          dd          	 |
                    |||	t          |                     }n# t          $ rC}t          t          j        |dt	          |                      t          dd|           d }~wt          $ rC}t          t          j        |dt	          |                      t          dd|           d }~ww xY wt          t          j        ||j        |j        |j        t	          |                      t%          d|j        t)          t+          j                              z
            }t-          |
          pd}t/          |d           }t1          ||j        |j        |t7          |           t9          |           !           t;          |t9          |           "           t=          |t9          |           "           |S )#Nmissing_pkce_cookie)re   rf   i  zMissing PKCE state cookier`   c              3  J   K   | ]}d |v |                     d d          V  dS )=   N)r=   )rX   segs     r5   	<genexpr>z auth_callback.<locals>.<genexpr>  s=        !C3JJ		#qJJJJ r7   rl   r]   r9   r|   verifierrI   zUnknown provider in cookie: 	idp_error)r]   re   r}   rf   zOAuth error from provider: z ()state_mismatchrd   z(OAuth state mismatch (CSRF check failed))r{   r|   code_verifierrb   invalid_codezInvalid code: rc   rP   rg   r]   user_idemailorg_idrf   <   /rh   ri   access_tokenrefresh_tokenaccess_token_expires_inrp   r3   r3   )r   r   r   rr   rB   r   dictr=   r<   r   complete_loginr6   r   r   LOGIN_SUCCESSr   r   r   max
expires_atinttimerK   r   r    r   r   r   rD   r   r   )r"   r{   r|   r}   r~   pkce_rawpartsprovider_nameexpected_stater   next_from_cookierY   sessionrw   
expires_inlandingrx   s                    r5   r+   r+      s       ((H 	
$('""	
 	
 	
 	

 .
 
 
 	
   %-^^C%8%8    E IIj"--MYYw++NyyR((H
 yy,,]##AyC-CC
 
 
 	

  
$"'""	
 	
 	
 	
 NNN:KNNN
 
 
 	

  

E^++$"#'""		
 	
 	
 	
 =
 
 
 	


"""&w//	 # 
 
  J J J$"!'""		
 	
 	
 	
 4HQ4H4HIIII 

 

 

$")'""		
 	
 	
 	
 /A//
 
 
 	


  m~g    R+c$)++.>.>>??J **:;;BsGS999D)+ *w''w    d77#3#34444 T''*:*:;;;;Ks$    &F' '
H?1>G//H?<>H::H?rawc                    | sdS ddl m}  ||                               d          r                    d          rdS t          fddD                       rdS dk    s                    d	          rdS S )
u  Return ``raw`` if it's a safe same-origin path, else empty string.

    The ``next`` query param survives a full OAuth round trip — the gate
    encodes it into the /login redirect, the login page emits it back into
    /auth/login, and the IDP preserves it across /authorize/callback. We
    have to re-validate here because the value came back in via the
    URL (an attacker could craft a /auth/callback URL with their own
    ``next=https://evil.example``).
    r9   r   )unquoter   z//c              3  N   K   | ]}|k    p                     |          V   d S )N)
startswith)rX   rY   decodeds     r5   r   z._validate_post_login_target.<locals>.<genexpr>  sN         	1-**1--     r7   )rE   z/auth/z
/api/auth/z/apiz/api/)r-   r   r   any)r   r   r   s     @r5   rK   rK   p  s      r$$$$$$gcllGc"" g&8&8&>&> r
    3      r &G..w77rNr7   
   g      N@zDict[str, Deque[float]]_pw_attemptsrf   rV   c                z   t          j                    }|t          z
  }| pd}t          5  t          |         }|r.|d         |k     r"|                                 |r|d         |k     "t          |          t          k    r	 ddd           dS |                    |           	 ddd           dS # 1 swxY w Y   dS )ui  True if ``ip`` has exceeded the password-login attempt budget.

    Sliding window: prune attempts older than the window, then check the
    count. Records the attempt timestamp when allowed. An empty IP (no
    discernible client) shares a single bucket — fail-safe toward
    throttling rather than letting unattributable traffic through
    unmetered.
    	_unknown_r   NTF)	r   	monotonic_PW_RATE_WINDOW_SEC_pw_attempts_lockr   popleftlen_PW_RATE_MAX_ATTEMPTSappend)rf   nowcutoffkeybuckets        r5   _password_rate_limitedr     s,    .

C&&F

C	  c" 	V++NN  	V++v;;///        	c                 s   AB0B00B47B4Nonec                 x    t           5  t                                           ddd           dS # 1 swxY w Y   dS )z(Test-only: clear all rate-limit buckets.N)r   r   clear r7   r5   _reset_password_rate_limitr     s~    	                   s   /33c                  <    e Zd ZU ded<   ded<   ded<   dZded<   dS )_PasswordLoginBodyr$   r]   usernamepasswordr9   rI   N)__name__
__module____qualname____annotations__rI   r   r7   r5   r   r     s8         MMMMMMMMMDNNNNNNr7   r   z/auth/password-loginauth_password_loginbodyc           
     v  K   t          |           }t          |          r3t          t          j        |j        d|           t          dd          t          |j                  }|t          |dd          s3t          t          j        |j        d	|           t          d
d          	 |	                    |j
        |j                  }n# t          $ r4 t          t          j        |j        d|           t          dd          t          $ r t          dd          t          $ r;}t          t          j        |j        d|           t          dd|           d}~ww xY wt          t          j        |j        |j        |j        |j        |           t'          d|j        t+          t-          j                              z
            }t/          |j                  pd}t3          d|d          }t5          ||j        |j        |t;          |           t=          |                      |S )u  Authenticate a username/password against a password provider.

    Mirrors the cookie-minting tail of ``/auth/callback`` but skips the
    PKCE/state/code machinery (those are OAuth-only). On success sets the
    session cookies and returns JSON ``{"ok": true, "next": <path>}`` —
    the credential form POSTs via fetch and navigates client-side, so a
    302 (which fetch follows opaquely) is the wrong shape here.

    Failure modes, all deliberately generic so the endpoint can't be used
    as a username oracle or a provider-enumeration oracle:
      * unknown provider / provider lacks password support → 404
      * bad credentials → 401 ("Invalid credentials")
      * backing store unreachable → 503
      * too many attempts from this IP → 429
    rate_limitedrd   i  z+Too many login attempts. Try again shortly.r`   NrT   Funknown_password_providerr_   zUnknown provider)r   r   invalid_credentials  zInvalid credentialsi  zProvider misconfiguredrc   rP   rg   r   r   r   T)okrI   r   )rB   r   r   r   rr   r]   r   r   rW   complete_password_loginr   r   r   NotImplementedErrorr   r   r   r   r   r   r   r   r   rK   rI   r   r    r   r   r   rD   )	r"   r   rf   rY   r   rw   r   r   rx   s	            r5   r   r     s     " 
G		Bb!! 

$]!		
 	
 	
 	
 @
 
 
 	

 	T]##Ay#6>>y 	$].		
 	
 	
 	
 4FGGGGR++]T] , 
 
 # K K K$](		
 	
 	
 	
 4IJJJJ N N N 4LMMMM R R R$])		
 	
 	
 	
 4PQ4P4PQQQQR  m~    R+c$)++.>.>>??J)$)44;GtW5566D)+ *w''w    Ks   /!C A E,16E''E,z/auth/logoutauth_logoutc                   K   t          |           \  }}|r`t                      D ]Q}	 |                    |           # t          $ r+}t                              d|j        |           Y d }~Jd }~ww xY wt          | j        dd           }t          t          j        |r|j        nd|r|j        ndt          |                      t          |           }t!          | dd	          }t#          ||
           t%          ||
           |S )N)r   z'dashboard-auth: revoke on %r failed: %sr   unknownr9   r]   r   rf   rE   rh   ri   r   )r   r   revoke_session	Exception_logwarningrG   rW   r|   r   r   LOGOUTr]   r   rB   rD   r   r   r   )r"   _atrtr]   rw   sessr3   rx   s           r5   r   r   $  s[     "7++GC	  '(( 	 	H''b'9999   =M1        7=)T22D#'6$--Y!%-2g	    WF6 1 1 1sCCCD$v....d6****Ks   ?
A4	!A//A4z/api/auth/meauth_mec                   K   t          | j        dd          }|t          dd          |j        |j        |j        |j        |j        |j        dS )zCReturn the verified session as JSON. Auth-required (gate enforces).r   Nr   Unauthorizedr`   )r   r   rU   r   r]   r   )	rW   r|   r   r   r   rU   r   r]   r   )r"   r   s     r5   api_auth_mer   H  sa       7=)T22D|NCCCC<)+Mo  r7   z/api/auth/ws-ticketauth_ws_ticketc                  K   t          | j        dd          }|t          dd          ddlm}m}  ||j        |j                  }t          t          j
        |j        |j        t          |           	           ||d
S )a  Mint a short-lived single-use ticket for the authenticated session.

    Browsers cannot set ``Authorization`` on a WebSocket upgrade, so in
    gated mode the SPA POSTs this endpoint to get a ``?ticket=`` value to
    append to ``/api/pty``, ``/api/console``, ``/api/ws``, ``/api/pub``, or
    ``/api/events``.

    The ticket has a 30-second TTL and is single-use. Calling this endpoint
    multiple times in quick succession (e.g. one ticket per WS) is the
    expected pattern.
    r   Nr   r   r`   r   )TTL_SECONDSmint_ticket)r   r]   r   )ticketttl_seconds)rW   r|   r   $hermes_cli.dashboard_auth.ws_ticketsr   r   r   r]   r   r   WS_TICKET_MINTEDrB   )r"   r   r   r   r   s        r5   api_auth_ws_ticketr   ]  s       7=)T22D|NCCCC NMMMMMMM[FFFF#g	    [999r7   )r"   r   r#   r$   )r"   r   r#   r   )r#   r   )r9   )r"   r   r]   r$   rI   r$   )r9   r9   r9   r9   )
r"   r   r{   r$   r|   r$   r}   r$   r~   r$   )r   r$   r#   r$   )rf   r$   r#   rV   )r#   r   )r"   r   r   r   )r"   r   )I__doc__
__future__r   logging	threadingr   collectionsr   r   typingr   r   r   r	   fastapir
   r   r   fastapi.responsesr   r   r   pydanticr   hermes_cli.dashboard_authr   r   r   hermes_cli.dashboard_auth.auditr   r   hermes_cli.dashboard_auth.baser   r   r   !hermes_cli.dashboard_auth.cookiesr   r   r   r   r   r   r   r    $hermes_cli.dashboard_auth.login_pager!   	getLoggerr   r   routerr6   rB   rD   r<   rF   r[   r\   r+   rK   r   r   r   r   Lockr   r   r   r   postr   r   r   r   r   r7   r5   <module>r      s     # " " " " "       * * * * * * * * * * * * * * * * * * * * 5 5 5 5 5 5 5 5 5 5 J J J J J J J J J J               
 B A A A A A A A         
	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 C B B B B Bw""	1F 1F 1F 1Fh9 9 9 9	( 	( 	( 	(" H<((   )(& !(899   :9: M--6 6 6 6 .-6r ?33 | | | | 43|~   `   (3E(:(: : : : :"IN$$    ,           #*?@@W W W A@Wt ^-00   10F N++   ,+( ")9::: : : ;:: : :r7   