
    $jX                       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 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) ddl*m+Z+  ej,        e-          Z. e            Z/dEdZ0dEdZ1dEdZ2e/3                    dd          dFd            Z4e/3                    dd          dGd            Z5e/3                    dd          dHdId#            Z6e/3                    d$d%          	 	 	 	 dJdKd*            Z7dLd,Z8d-Z9d.Z: ee          Z;d/e<d0<    ej=                    Z>dMd3Z?dNd5Z@ G d6 d7e          ZAe/B                    d8d9          dOd;            ZCe/B                    d<d=          dPd>            ZDe/3                    d?d@          dPdA            ZEe/B                    dBdC          dPdD            ZFd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)
AuditEvent	audit_log)InvalidCodeErrorInvalidCredentialsErrorProviderError)clear_pkce_cookieclear_session_cookies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	            ?/usr/local/lib/hermes-agent/hermes_cli/dashboard_auth/routes.py_redirect_urir4   6   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     r3   
_client_ipr@   j   sZ    
/

/
4
4C
 )yy~~a &&(((").87>b8r5   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     r3   _prefixrB   q   s(     EDDDDDw'''r5   /login
login_page)namer   c                   K   t          | j                            dd                    }t          t	          |          ddi          S )Nnextr7   )	next_pathzCache-Controlz#no-store, no-cache, must-revalidate)r9   )_validate_post_login_targetquery_paramsr:   r   r   )r    rH   s     r3   rD   rD      s]       ,  ,, I I... "GH   r5   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)rE   display_namerR   )rE   rS   boolgetattr).0ps     r3   
<listcomp>z&api_auth_providers.<locals>.<listcomp>   sX     	
 	
 	
   !%)A2E::& & 	
 	
 	
r5   )r   r   )rP   s    r3   api_auth_providersrY      si        I 
56
 
 
 	

 	 	
 	
 	
 	
 	
 r5   z/auth/login
auth_loginr7   providerrG   c           	       K   t          |          }|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: rO   rM   )redirect_uriprovider_unreachabler[   reasoniprN   Provider unreachable: )r[   rc   .  urlrO   hermes_session_pkcer7   z	provider=;r   )quotez;next=)safe)payload	use_httpsr1   )r   r   start_loginr4   r   r   r   LOGIN_FAILUREr@   LOGIN_STARTr   redirect_urlcookie_payloadr:   rI   r+   rj   r   r   rB   )
r    r[   rG   rW   lseresppkce	safe_nextrj   s
             r3   rZ   rZ      s     XAy444
 
 
 	


]]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   #A 
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          |           "           |S )#Nmissing_pkce_cookie)rb   rc   i  zMissing PKCE state cookier^   c              3  J   K   | ]}d |v |                     d d          V  dS )=   N)r;   )rV   segs     r3   	<genexpr>z auth_callback.<locals>.<genexpr>   s=        !C3JJ		#qJJJJ r5   ri   r[   r7   ry   verifierrG   zUnknown provider in cookie: 	idp_error)r[   rb   rz   rc   zOAuth error from provider: z ()state_mismatchra   z(OAuth state mismatch (CSRF check failed))rx   ry   code_verifierr_   invalid_codezInvalid code: r`   rN   rd   r[   user_idemailorg_idrc   <   /re   rf   access_tokenrefresh_tokenaccess_token_expires_inrm   r1   r1   )r   r   r   ro   r@   r   dictr;   r:   r   complete_loginr4   r   r   LOGIN_SUCCESSr   r   r   max
expires_atinttimerI   r   r   r   r   r   rB   r   )r    rx   ry   rz   r{   pkce_rawpartsprovider_nameexpected_stater   next_from_cookierW   sessionrt   
expires_inlandingru   s                    r3   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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``).
    r7   r   )unquoter   z//c              3  N   K   | ]}|k    p                     |          V   d S )N)
startswith)rV   rW   decodeds     r3   r   z._validate_post_login_target.<locals>.<genexpr>u  sN         	1-**1--     r5   )rC   z/auth/z
/api/auth/z/apiz/api/)r+   r   r   any)r   r   r   s     @r3   rI   rI   d  s      r$$$$$$gcllGc"" g&8&8&>&> r
    3      r &G..w77rNr5   
   g      N@zDict[str, Deque[float]]_pw_attemptsrc   rT   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)rc   nowcutoffkeybuckets        r3   _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 r5   r3   _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passwordr7   rG   N)__name__
__module____qualname____annotations__rG   r   r5   r3   r   r     s8         MMMMMMMMMDNNNNNNr5   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_limitedra   i  z+Too many login attempts. Try again shortly.r^   NrR   Funknown_password_providerr]   zUnknown provider)r   r   invalid_credentials  zInvalid credentialsi  zProvider misconfiguredr`   rN   rd   r   r   r   T)okrG   r   )r@   r   r   r   ro   r[   r   r   rU   complete_password_loginr   r   r   NotImplementedErrorr   r   r   r   r   r   r   r   r   rI   rG   r   r   r   r   r   rB   )	r    r   rc   rW   r   rt   r   r   ru   s	            r3   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   unknownr7   r[   r   rc   rC   re   rf   r   )r   r   revoke_session	Exception_logwarningrE   rU   ry   r   r   LOGOUTr[   r   r@   rB   r   r   r   )r    _atrtr[   rt   sessr1   ru   s           r3   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   rS   r   r[   r   )	rU   ry   r   r   r   rS   r   r[   r   )r    r   s     r3   api_auth_mer   <  sa       7=)T22D|NCCCC<)+Mo  r5   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/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)rU   ry   r   $hermes_cli.dashboard_auth.ws_ticketsr   r   r   r[   r   r   WS_TICKET_MINTEDr@   )r    r   r   r   r   s        r3   api_auth_ws_ticketr   Q  s       7=)T22D|NCCCC NMMMMMMM[FFFF#g	    [999r5   )r    r   r!   r"   )r    r   r!   r   )r!   r   )r7   )r    r   r[   r"   rG   r"   )r7   r7   r7   r7   )
r    r   rx   r"   ry   r"   rz   r"   r{   r"   )r   r"   r!   r"   )rc   r"   r!   rT   )r!   r   )r    r   r   r   )r    r   )G__doc__
__future__r   logging	threadingr   collectionsr   r   typingr   r   r   r	   fastapir
   r   r   fastapi.responsesr   r   r   pydanticr   hermes_cli.dashboard_authr   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   $hermes_cli.dashboard_auth.login_pager   	getLoggerr   r   routerr4   r@   rB   r:   rD   rY   rZ   r)   rI   r   r   r   r   Lockr   r   r   r   postr   r   r   r   r   r5   r3   <module>r      sq     # " " " " "       * * * * * * * * * * * * * * * * * * * * 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6 M--1 1 1 1 .-1h ?33 y y y y 43yx   `   (3E(:(: : : : :"IN$$    ,           #*?@@W W W A@Wt ^-00   10F N++   ,+( ")9::: : : ;:: : :r5   