
    `gj\                    p   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       Z1d3dZ2d3dZ3d3dZ4e1jk                  dd      d4d       Z6e1jk                  dd      d5d       Z7e1jk                  dd      d6d7d       Z8e1jk                  dd      	 	 	 	 d8	 	 	 	 	 	 	 	 	 d9d       Z9d:dZ:dZ;d Z< ee      Z=d!e>d"<    ej~                         Z@d;d#ZAd<d$ZB G d% d&e      ZCe1j                  d'd(      d=d)       ZEe1j                  d*d+      d>d,       ZFe1jk                  d-d.      d>d/       ZGe1j                  d0d1      d>d2       ZHy)?u  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_htmlc                    ddl m}m} ddlm}m}  |       }|r| dS t        | j                  d            } ||       }|s|S  ||      } ||j                  | |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&   strurl_for_replacer)   )	requestr#   r$   r%   r&   
public_urlbaseprefixparseds	            S/root/.hermes/venv/lib/python3.12/site-packages/hermes_cli/dashboard_auth/routes.py_redirect_urir5   8   s{    4 2 $%J
 ^,, w/0D )Fd^FfoofXfkk]+CoDEE    c                    | j                   j                  dd      }|r"|j                  d      d   j                         S | j                  r| j                  j
                  S dS )Nzx-forwarded-for ,r   )headersgetsplitstripclienthost)r/   fwds     r4   
_client_iprA   l   sS    
//

/
4C
yy~a &&((")..7>>8b8r6   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     r4   _prefixrC   s   s     Ew''r6   /login
login_page)namec                   K   t        | j                  j                  dd            }t        t	        |      ddi      S w)Nnextr8   )	next_pathzCache-Controlz#no-store, no-cache, must-revalidate)r:   )_validate_post_login_targetquery_paramsr;   r   r!   )r/   rI   s     r4   rE   rE      sH      ,  ,I I. "GH s   ?Az/api/auth/providersauth_providersc                    K   t               } | st        ddid      S d| D cg c]0  }|j                  |j                  t	        t        |dd            d2 c}iS c c}w w)	Ndetailzno auth providers registered  )status_code	providerssupports_passwordF)rF   display_namerR   )r   r   rF   rS   boolgetattr)rQ   ps     r4   api_auth_providersrW      s      '(I56
 	

 	 	
   !%)A2E:&	
 	
s   !A!5A	A!z/auth/login
auth_loginc           	     8  K   t        |      }|t        dd|      t        |dd      st        dd|      t        |dd      r=d	d
lm} t        |      }t        |        d}|r| d ||d       }t        |d      S 	 |j                  t        |             }t        t        j                  |t        |              t        |j                   d      }	|j"                  j%                  dd      }
d|
vr|
rd| d|
 nd| }
t        |      }|rd	d
lm} |
 d ||d       }
t'        |	|
t)        |       t        |              |	S # t        $ r;}t        t        j                  |dt        |              t        dd|       d }~ww xY ww)N  zUnknown provider: rP   rN   supports_sessionTz-Provider does not support interactive login: rR   Fr   )quoterD   z?next=r8   )safe.  urlrP   )redirect_uriprovider_unreachableproviderreasoniprO   Provider unreachable: )re   rg   hermes_session_pkcez	provider=;z;next=)payload	use_httpsr2   )r   r   rU   r*   r]   rJ   rC   r   start_loginr5   r   r   r   LOGIN_FAILURErA   LOGIN_STARTredirect_urlcookie_payloadr;   r   r   )r/   re   rH   rV   r]   	safe_next	login_urllseresppkces              r4   rX   rX      s    XAy'|4
 	
 1($/B8,O
 	
 q%u-&/5	w'(/	$+VE)",E+FGII3??
]]g(>]? g SAD   !6;D$048*AdV,IhZ:P ,D1I&veIB789dl7&;w KO  

$$)'"		
 +A3/
 	


s+   BFE #B0F	F6FFFr'   r(   c           
       K   t        |       }|s2t        t        j                  dt	        |              t        dd      t        d |j                  d      D              }|j                  dd	      }|j                  d
d	      }|j                  dd	      }	|j                  dd	      }
t        |      }|t        dd|      |r;t        t        j                  |d|t	        |              t        dd| d| d      |r||k7  r3t        t        j                  |dt	        |              t        dd      	 |j                  |||	t        |             }t        t        j                  ||j                  |j                   |j"                  t	        |              t%        d|j&                  t)        t+        j*                               z
        }t-        |
      xs d}t/        |d       }t1        ||j2                  |j4                  |t7        |       t9        |       |j:                  !       t=        |t9        |       "       t?        |t9        |       "       |S # t        $ r;}t        t        j                  |dt	        |              t        dd|       d }~wt        $ r;}t        t        j                  |dt	        |              t        dd|       d }~ww xY ww)#Nmissing_pkce_cookie)rf   rg   i  zMissing PKCE state cookier[   c              3  J   K   | ]  }d |v s|j                  d d        yw)=   N)r<   ).0segs     r4   	<genexpr>z auth_callback.<locals>.<genexpr>  s&      !C3J		#qs   	##rj   re   r8   stateverifierrH   zUnknown provider in cookie: 	idp_error)re   rf   errorrg   zOAuth error from provider: z ()state_mismatchrd   z(OAuth state mismatch (CSRF check failed))coder   code_verifierrb   invalid_codezInvalid code: rc   rO   rh   re   user_idemailorg_idrg   <   /r_   r`   access_tokenrefresh_tokenaccess_token_expires_inrl   r2   re   r2   ) r   r   r   rn   rA   r   dictr<   r;   r   complete_loginr5   r   r   LOGIN_SUCCESSr   r   r   max
expires_atinttimerJ   r   r    r   r   r   rC   re   r   r   )r/   r   r   r   error_descriptionpkce_rawpartsprovider_nameexpected_stater   next_from_cookierV   sessionru   
expires_inlandingrv   s                    r4   r(   r(      s      (H$$('"	

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

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

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


"""&w/	 # 
4   mm~~g R++c$))+.>>?J **:;BsGS9D))++ *w'w!! d77#34 T''*:;Kg  J$$"!'"		
 nQC4HII 

$$")'"		
 +A3/
 	


s7   D=K- I$ DK-$	K*-6J##K*/6K%%K**K-c                    | syddl m}  ||       j                  d      rj                  d      ryt        fddD              rydk(  sj                  d	      ry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``).
    r8   r   )unquoter   z//c              3  N   K   | ]  }|k(  xs j                  |        y w)N)
startswith)r}   rV   decodeds     r4   r   z._validate_post_login_target.<locals>.<genexpr>  s0       	1-**1--s   "%)rD   z/auth/z
/api/auth/z/apiz/api/)r*   r   r   any)rawr   r   s     @r4   rJ   rJ   y  sn     $clGc"g&8&8&>
 3   &G..w7Nr6   
   g      N@zDict[str, Deque[float]]_pw_attemptsc                8   t        j                         }|t        z
  }| xs d}t        5  t        |   }|r#|d   |k  r|j                          |r	|d   |k  rt        |      t        k\  r
	 ddd       y|j                  |       	 ddd       y# 1 sw Y   yxY w)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)rg   nowcutoffkeybuckets        r4   _password_rate_limitedr     s     ..
C&&F

C	 c"V+NN V+v;//  	c  s   .BB5BBc                 b    t         5  t        j                          ddd       y# 1 sw Y   yxY w)z(Test-only: clear all rate-limit buckets.N)r   r   clear r6   r4   _reset_password_rate_limitr     s&    	   s   %.c                  :    e Zd ZU ded<   ded<   ded<   dZded<   y)_PasswordLoginBodyr,   re   usernamepasswordr8   rH   N)__name__
__module____qualname____annotations__rH   r   r6   r4   r   r     s    MMMD#Nr6   r   z/auth/password-loginauth_password_loginc           
       K   t        |       }t        |      r4t        t        j                  |j
                  d|       t        dd      t        |j
                        }|t        |dd      s4t        t        j                  |j
                  d	|       t        d
d      	 |j                  |j                  |j                        }t        t        j                  |j
                  |j                   |j"                  |j$                  |       t'        d|j(                  t+        t-        j,                               z
        }t/        |j0                        xs d}t3        d|d      }t5        ||j6                  |j8                  |t;        |       t=        |       |j
                         |S # t        $ r5 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w)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[   NrR   Funknown_password_providerrZ   zUnknown provider)r   r   invalid_credentials  zInvalid credentialsi  zProvider misconfiguredrc   rO   rh   r   r   r   T)okrH   r   )rA   r   r   r   rn   re   r   r   rU   complete_password_loginr   r   r   NotImplementedErrorr   r   r   r   r   r   r   r   r   rJ   rH   r   r    r   r   r   rC   )	r/   bodyrg   rV   r   ru   r   r   rv   s	            r4   r   r     s    " 
G	Bb!$$]]!		
 @
 	

 	T]]#Ay#6> 	$$]].		
 4FGGR++]]T]] , 
2   mm~~ R++c$))+.>>?J)$))4;GtW56D))++ *w'w!! KW # K$$]](		
 4IJJ N 4LMM R$$]])		
 6LQC4PQQRs,   B#I&'F3 C&I3AI	7II		Iz/auth/logoutauth_logoutc                  K   t        |       \  }}|r"t               D ]  }	 |j                  |        t        | j                  dd       }t        t        j                  |r|j                  nd|r|j                  ndt        |              t        |       }t!        | dd	      }t#        ||
       t%        ||
       |S # t        $ r+}t        j                  d|j                  |       Y d }~d }~ww xY ww)N)r   z'dashboard-auth: revoke on %r failed: %sr   unknownr8   re   r   rg   rD   r_   r`   r   )r   r   revoke_session	Exception_logwarningrF   rU   r   r   r   LOGOUTre   r   rA   rC   r   r   r   )r/   _atrtre   ru   sessr2   rv   s           r4   r   r   .  s     "7+GC	 '( 	H''b'9	 7==)T2D#'$--Y!%2g	 WF6(& 1sCD$v.d6*K%  =MM1 s.   C<CBC<	C9!C4/C<4C99C<z/api/auth/meauth_mec                   K   t        | j                  dd      }|t        dd      |j                  |j                  |j
                  |j                  |j                  |j                  dS w)zCReturn the verified session as JSON. Auth-required (gate enforces).r   Nr   Unauthorizedr[   )r   r   rS   r   re   r   )	rU   r   r   r   r   rS   r   re   r   )r/   r   s     r4   api_auth_mer   R  sb      7==)T2D|NCC<<))++MMoo s   A,A.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 w)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   re   r   )ticketttl_seconds)rU   r   r   $hermes_cli.dashboard_auth.ws_ticketsr   r   r   re   r   r   WS_TICKET_MINTEDrA   )r/   r   r   r   r   s        r4   api_auth_ws_ticketr   g  sz      7==)T2D|NCC NFF##g	 [99s   BB)r/   r   returnr,   )r/   r   r   r   )r   r   )r8   )r/   r   re   r,   rH   r,   )r8   r8   r8   r8   )
r/   r   r   r,   r   r,   r   r,   r   r,   )r   r,   r   r,   )rg   r,   r   rT   )r   None)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   routerr5   rA   rC   r;   rE   rW   rX   r(   rJ   r   r   r   r   Lockr   r   r   r   postr   r   r   r   r   r6   r4   <module>r      s+   #    * * * 5 5 J J  
 B 
	 	 	 Cw"	1Fh9	(" H<( )& !(89 :: M-> .>B ?3 }}
} } 	}
 } 4}@`   (3E(:% :"INN$ ,  #*?@X AXv ^-0 1F N+ ,( ")9:: ;:r6   