
    #j'p                    
   U d Z ddlmZ ddlZddlZddlZddlZddlZddlZ	ddl
mZmZmZ ddlZddlmZmZmZmZmZmZ  ej        e          ZdZdaded	<   d
ZdZdZdZddZ  G d de          Z!ddZ"ddZ#ddZ$ddZ%dS )u1  NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE).

Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
(PR #180). The plugin auto-loads (bundled, kind=backend) but only registers
its provider when a client_id is configured — either via ``config.yaml`` or
via the Portal-injected env var — so loopback / ``--insecure`` operators
are unaffected.

Configuration surfaces (env wins over config.yaml when set non-empty):

  ``config.yaml`` — canonical surface::

      dashboard:
        oauth:
          client_id: agent:{agent_instance_id}   # required
          portal_url: https://portal.example     # optional

  Environment overrides — used by Fly.io's platform-secret injection so
  per-deploy values don't need to bake into ``config.yaml``:

      HERMES_DASHBOARD_OAUTH_CLIENT_ID  — shape ``agent:{agent_instance_id}``
      HERMES_DASHBOARD_PORTAL_URL       — defaults to
                                          ``https://portal.nousresearch.com``
                                          (production Portal). Override only
                                          for staging (``portal.rewbs.uk``)
                                          or a custom deployment.

Empty env var values are treated as unset so a provisioned-but-not-populated
Fly secret can't shadow a valid config.yaml entry.

Key contract points encoded here:

  - client_id is per-instance (``agent:{instance_id}``); the suffix is also
    cross-checked against the token's ``agent_instance_id`` claim as
    defense-in-depth.
  - scope is ``agent_dashboard:access`` only (no OIDC scopes).
  - tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
    JWKS is cached for 5 minutes.
  - the dashboard auth-code grant issues a 24h rotating refresh token
    (Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
    to rotate the access token; ``complete_login`` and ``refresh_session``
    both populate ``Session.refresh_token`` with the (rotating) value the
    middleware persists back to the HttpOnly cookie. On a dead/expired/
    reuse-detected refresh token Portal returns 400 → ``RefreshExpiredError``
    → middleware redirects to ``/auth/login``.
  - audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
  - tolerant ``oauth_contract_version`` check: missing → warn + proceed;
    present and ``!= 1`` → refuse.

The cookie payload returned by ``start_login`` stashes the PKCE
``code_verifier`` and the OAuth ``state`` parameter for the
``/auth/callback`` handler to retrieve. The auth-route layer is the owner
of cookie names; this provider just hands back ``{"code_verifier": …,
"state": …}`` and the route serializes those into the ``hermes_session_pkce``
cookie.

Refresh-token rotation: Portal rotates the refresh token on every
successful refresh and runs reuse-detection (replaying a rotated token
outside Portal's 60s grace revokes the whole session). The host
middleware therefore MUST persist the rotated ``Session.refresh_token``
back to the cookie on every refresh.

Skip reasons:
  The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
  fail-closed branch reads to surface a useful operator error message
  ("Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …") instead of the bare "no
  providers registered" the gate would otherwise emit.
    )annotationsN)AnyDictOptional)DashboardAuthProviderInvalidCodeError
LoginStartProviderErrorRefreshExpiredErrorSessionzhttps://portal.nousresearch.com strLAST_SKIP_REASONzagent_dashboard:access   i,  g      $@rawbytesreturnc                t    t          j        |                               d                                          S )u6   Base64url-encode without ``=`` padding (RFC 7636 §4).   =)base64urlsafe_b64encoderstripdecode)r   s    I/home/ubuntu/.hermes/hermes-agent/plugins/dashboard_auth/nous/__init__.py_b64url_no_padr      s-    #C((//55<<>>>    c                      e Zd ZdZdZdZd(d	Zd)dZd*dZd+dZ	d,dZ
d-dZd.dZd/dZd0dZd1d!Zd2d"Zd3d$Zd3d%Zd4d&Zd'S )5NousDashboardAuthProviderz7Nous Portal OAuth via authorization-code + PKCE (S256).nouszNous Research	client_idr   
portal_urlr   Nonec               6   |                     d          st          d|          || _        |t          d          d          | _        |                    d          | _        | j         d| _        | j         d| _        | j         d| _	        d | _
        d S )Nagent:z?client_id must match contract shape 'agent:{instance_id}', got /z/.well-known/jwks.jsonz/oauth/authorizez/api/oauth/token)
startswith
ValueError
_client_idlen_agent_instance_idr   _portal_url	_jwks_url_authorize_url
_token_url_jwks_client)selfr    r!   s      r   __init__z"NousDashboardAuthProvider.__init__   s    ##H-- 	 % % %   $"+CMMOO"<%,,S11 ,DDD!%!1CCC!-??? "&r   redirect_urir	   c                  |                      |           t          t          j        d                    }t          t	          j        |                    d                                                              }t          t          j        d                    }d| j        |t          ||dd}| j
         dt          j                            |           }dd	| d
| i}t          ||          S )N@   ascii    codeS256)response_typer    r2   scopestatecode_challengecode_challenge_method?hermes_session_pkcezstate=z
;verifier=)redirect_urlcookie_payload)_validate_redirect_urir   secretstoken_byteshashlibsha256encodedigestr(   _SCOPEr-   urllibparse	urlencoder	   )r0   r2   code_verifierr<   r;   paramsr@   rA   s           r   start_loginz%NousDashboardAuthProvider.start_login   s    ##L111&w':2'>'>??'N=//8899@@BB
 
 w226677 $(,%+
 
 -PP0F0Fv0N0NPP "#LE#L#L]#L#L
 |NSSSSr   r7   r;   rM   r   c          	         |}	 t          j        | j        d||| j        |dddit                    }n*# t           j        $ r}t          d|           |d }~ww xY w|                     |t                    S )Nauthorization_code)
grant_typer7   r2   r    rM   Acceptapplication/jsondataheaderstimeout#Portal token endpoint unreachable: bad_request_exc)	httpxpostr.   r(   _TOKEN_ENDPOINT_TIMEOUT_SECRequestErrorr
   _token_response_to_sessionr   )r0   r7   r;   rM   r2   _responseexcs           r   complete_loginz(NousDashboardAuthProvider.complete_login   s     	Vz"6 $0!%%2  "#563  HH ! 	V 	V 	V Kc K KLLRUU	V ..&6 / 
 
 	
s   /4 AAArefresh_tokenc                  |st          d          	 t          j        | j        d| j        |dd|dt
                    }n*# t          j        $ r}t          d|           |d}~ww xY w|                     |t           	          S )
u  Rotate the access token using the refresh token.

        Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
        refresh token is sent in the ``X-Refresh-Token`` header (not the body)
        so it never lands in Portal's request-body access logs — mirroring the
        device-flow CLI convention; Portal reconciles header vs. body and
        rejects conflicts.

        Portal rotates the refresh token on every successful refresh, so the
        returned ``Session.refresh_token`` is a NEW value the caller MUST
        persist (replacing the old cookie). Failing to persist it means the
        next refresh replays a rotated token and — outside Portal's 60s grace
        — trips reuse-detection and revokes the whole session.

        Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
        detected), so the middleware clears cookies and forces re-login.
        Raises ``ProviderError`` if Portal is unreachable.
        z#no refresh token present in sessionre   )rR   r    re   rT   )rS   zx-nous-refresh-tokenrU   rY   NrZ   )	r   r\   r]   r.   r(   r^   r_   r
   r`   )r0   re   rb   rc   s       r   refresh_sessionz)NousDashboardAuthProvider.refresh_session   s    &  	M &&KLLL	z #2!%%2  1,9  4-  HH0 ! 	 	 	;c;; 	 ..&9 / 
 
 	
s   .A A)A$$A)rb   httpx.Responser[   type[Exception]c                  |j         dk    r9|                     |          }|                    dd          } |d|           |j         dk    r't          d|j          d|j        dd                   |                     |          }|                    d	          }|rt          |t                    st          d
          t          |                    dd                                                    }|r|dk    rt          d|          |                     |          }|                    d          pd}	t          |	t                    sd}	| 	                    ||	|          S )u  Translate a Portal ``/api/oauth/token`` response into a Session.

        Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
        (refresh grant). ``bad_request_exc`` is the exception type raised on a
        400 — ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
        for the refresh path — so the middleware's distinct handling
        (400-on-callback vs. force-relogin) is preserved.
        i  errorinvalid_requestzPortal rejected token request:    zPortal token endpoint returned z: Naccess_tokenz*Portal token response missing access_token
token_typer   bearerzunexpected token_type=re   )
status_code_parse_json_bodygetr
   text
isinstancer   lower_verify_jwt_session_from_claims)
r0   rb   r[   body
error_codepayloadrn   ro   claimsre   s
             r   r`   z4NousDashboardAuthProvider._token_response_to_session0  s    3&& ((22D'+<==J!/"PJ"P"PQQQ3&&+(2F + +=#&+ +  
 ''11{{>22 	N:lC#@#@ 	N LMMM\26677==??
 	I*00 G G GHHH!!,//  O44:--- 	M((}fMMMr   rn   Optional[Session]c                   	 |                      |          }n# t          $ r Y d S t          $ r  w xY w|                     |d|          S )Nr   )rw   r   r
   rx   )r0   rn   r|   s      r   verify_sessionz(NousDashboardAuthProvider.verify_session^  sm    
	%%l33FF 	 	 	44 	 	 		 ((r6BBBs    
00c               
    |}d S )N )r0   re   ra   s      r   revoke_sessionz(NousDashboardAuthProvider.revoke_sessionp  s     tr   c                    t           j                            |          }|j        dvrt	          d|          |j        r|j                            d          st	          d|          dS )u  Surface obviously-broken redirect_uris before bouncing to Portal.

        The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
        this is a fast-fail for the common operator-error case. We allow any
        ``http://`` host (not just localhost) so self-hosted dashboards reached
        over plain HTTP — LAN IPs, internal hostnames, reverse proxies that
        terminate TLS upstream — are not rejected here; Portal makes the final
        call on which redirect_uris are permitted.
        )httpshttpz"redirect_uri must be http(s), got z/auth/callbackz6redirect_uri path must end with '/auth/callback', got N)rJ   rK   urlparseschemer
   pathendswith)r0   r2   parseds      r   rB   z0NousDashboardAuthProvider._validate_redirect_uri  s     &&|44= 111E\EE   { 	&+"6"67G"H"H 	(#( (  	 	r   Dict[str, Any]c                    |j                             dd          }|                    d          si S 	 |                                }n# t          $ r i cY S w xY wt          |t                    r|ni S )Nzcontent-typer   rT   )rW   rs   r&   jsonr'   ru   dict)r0   rb   ctypery   s       r   rr   z*NousDashboardAuthProvider._parse_json_body  s     $$^R88 233 	I	==??DD 	 	 	III	!$--5tt25s   A	 	AAr   c                d    | j         #ddlm}  || j        dt                    | _         | j         S )Nr   )PyJWKClientT)
cache_keyslifespan)r/   jwtr   r,   _JWKS_CACHE_SECONDS)r0   r   s     r   _get_jwks_clientz*NousDashboardAuthProvider._get_jwks_client  sL    $'''''' +,! ! !D
   r   c           
        dd l }	 |                                                     |          }nE# |j        $ r}t	          d|           |d }~wt
          $ r}t	          d|          |d }~ww xY w	 |                    ||j        dg| j        | j	        dg di          }n# |j
        $ r}t          d|           |d }~w|j        $ r}d}	 |                    |d	d	d
          }d|                    d          d|                    d          d| j	        d| j        d	}n# t
          $ r Y nw xY wt	          d| |           |d }~ww xY w|                     |           |                     |           |S )Nr   zJWKS lookup failed: RS256require)expiataudisssub)
algorithmsaudienceissueroptionszaccess token expired: r   F)verify_signature
verify_exp)r   z [token iss=r   z aud=r   z; expected iss=]z"access token verification failed: )r   r   get_signing_key_from_jwtPyJWKClientErrorr
   	Exceptionr   keyr(   r+   ExpiredSignatureErrorr   InvalidTokenErrorrs   _check_agent_instance_id_check_contract_version)r0   rn   r   signing_keyrc   r|   details
unverifieds           r   rw   z%NousDashboardAuthProvider._verify_jwt  sV    	


	I//11JJ KK # 	G 	G 	G <s < <==3F 	I 	I 	I >s > >??SH	I%	ZZ#9'"$G$G$GH   	 	FF ( 	L 	L 	L"#AC#A#ABBK$ 	 	 	 G ZZ 16eLL (  

0:>>%#8#8 0 0%>>%000 0$($40 0  ?0 0 0     CSC'CC +	2 	%%f---$$V,,,si   '. 
A0AA0A++A04/B$ $
E.CEEAD+*E+
D85E7D88EEr|   c                    |                     d          }|dS || j        k    rt          d|d| j                  dS )z>Contract C9: cross-check agent_instance_id against our config.agent_instance_idNz"agent_instance_id mismatch: token=z vs configured=)rs   r*   r
   )r0   r|   token_instance_ids      r   r   z2NousDashboardAuthProvider._check_agent_instance_id  sl    "JJ':;;$ F 777=5F = =!%!8= =   87r   c                    |                     d          }|"t                              dt                     dS |t          k    rt	          d|dt                     dS )u.   Contract C11 — tolerant treatment per OQ-C2.oauth_contract_versionNzjNous Portal token missing oauth_contract_version claim (contract says it should be %d); proceeding anyway.z#unsupported oauth_contract_version=z, expected )rs   loggerwarning_EXPECTED_CONTRACT_VERSIONr
   )r0   r|   contract_versions      r   r   z1NousDashboardAuthProvider._check_contract_version  s    !::&>??#NNF*  
 F99996F 9 969 9   :9r   c                   t          |                    dd                    }|st          d          t          |ddt          |                    d          pd          | j        t          |d                   ||          S )Nr   r   z#token missing 'sub' (user_id) claimorg_idr   )user_idemaildisplay_namer   provider
expires_atrn   re   )r   rs   r
   r   nameint)r0   rn   re   r|   r   s        r   rx   z.NousDashboardAuthProvider._session_from_claims  s     fjj++,, 	G EFFFvzz(++1r22Y6%=))%'	
 	
 	
 		
r   N)r    r   r!   r   r   r"   )r2   r   r   r	   )
r7   r   r;   r   rM   r   r2   r   r   r   )re   r   r   r   )rb   rh   r[   ri   r   r   )rn   r   r   r}   )re   r   r   r"   )r2   r   r   r"   )rb   rh   r   r   )r   r   )rn   r   r   r   )r|   r   r   r"   )rn   r   re   r   r|   r   r   r   )__name__
__module____qualname____doc__r   r   r1   rO   rd   rg   r`   r   r   rB   rr   r   rw   r   r   rx   r   r   r   r   r      sL       AAD"L& & & &(T T T T8#
 #
 #
 #
J:
 :
 :
 :
x+N +N +N +N\C C C C$       *6 6 6 6	! 	! 	! 	!7 7 7 7r       
 
 
 
 
 
r   r   r   c                     	 ddl m} m}  |            }n4# t          $ r'}t                              d|           i cY d}~S d}~ww xY w | |ddd          }t          |t                    r|ni S )u  Return the ``dashboard.oauth`` block from ``config.yaml`` if it
    exists and is a dict; otherwise an empty dict.

    Robust to (a) load_config() raising (malformed YAML, IO error,
    config.yaml absent — common in fresh installs), (b) the
    ``dashboard`` key being absent or non-dict, and (c) the ``oauth``
    sub-key being present but not a dict (user typo). Each shape falls
    through to ``{}`` so register() can rely on `.get(...)` access.
    r   )cfg_getload_configzTdashboard-auth-nous: load_config() raised %s; falling back to env-only configurationN	dashboardoauth)default)hermes_cli.configr   r   r   r   debugru   r   )r   r   cfgrc   sections        r   _load_config_oauth_sectionr     s    
::::::::kmm   5	
 	
 	

 						 gc;>>>G $//777R7s    
AAAAc                     t           j                            dd                                          } | r| S t	                                          dd          }t          |                                          S )u  Resolve the OAuth client_id with env-overrides-config precedence.

    Order:
      1. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var (when non-empty
         after strip — empty values are treated as unset so a
         provisioned-but-not-populated Fly secret can't shadow a valid
         config.yaml entry).
      2. ``dashboard.oauth.client_id`` in ``config.yaml``.
      3. Empty string — signals "no client_id configured" to the caller.
     HERMES_DASHBOARD_OAUTH_CLIENT_IDr   r    )osenvironrs   stripr   r   env	cfg_values     r   _resolve_client_idr   6  sf     *..;R
@
@
F
F
H
HC
 
*,,00bAAIy>>!!!r   c                    t           j                            dd                                          } | r| S t	          t                                          dd                                                    }|pt          S )a  Resolve the Portal URL with env-overrides-config precedence.

    Order:
      1. ``HERMES_DASHBOARD_PORTAL_URL`` env var (non-empty after strip).
      2. ``dashboard.oauth.portal_url`` in ``config.yaml``.
      3. :data:`_DEFAULT_PORTAL_URL` (production Portal).
    HERMES_DASHBOARD_PORTAL_URLr   r!   )r   r   rs   r   r   r   _DEFAULT_PORTAL_URLr   s     r   _resolve_portal_urlr   H  su     *..6
;
;
A
A
C
CC
 
"$$((r:: egg  +++r   r"   c                
   da t                      }t                      }|s$da t                              dt                      dS |                    d          s(d|da t                              dt                      dS 	 t          ||          }n=# t          $ r0}d	| a t                              dt                      Y d}~dS d}~ww xY w| 	                    |           t          
                    d
||           dS )u_  Plugin entry — called by the plugin loader at startup.

    Registers ``NousDashboardAuthProvider`` only when a client_id is
    configured (either via ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var
    or via ``dashboard.oauth.client_id`` in ``config.yaml``). The env
    var wins when set non-empty — Fly.io's platform-secret injection
    pushes the per-deploy value through this path.

    When skipping, writes a short human-readable reason to the module-
    level :data:`LAST_SKIP_REASON` so the dashboard's fail-closed branch
    can surface "Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …" instead of the
    bare "no providers registered" the gate would otherwise emit. The
    reason mentions BOTH configuration surfaces so operators don't
    guess wrong about which one to populate.

    Operator-owned dashboards (loopback / ``--insecure``) leave both
    surfaces unset, so this plugin is a no-op for them. The gate-
    engagement layer (``hermes_cli.web_server.should_require_auth`` +
    the fail-closed check in ``start_server``) handles the "public bind
    with zero providers" case independently.
    r   uy  HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and dashboard.oauth.client_id in config.yaml is empty). The Nous Portal provisions this env var (shape 'agent:{instance_id}') when it deploys a Hermes Agent instance — set it to your provisioned client id (either as an env var or under dashboard.oauth.client_id in config.yaml), or pass --insecure to skip the OAuth gate entirely.zdashboard-auth-nous: %sNr$   z!HERMES_DASHBOARD_OAUTH_CLIENT_ID=z doesn't match the contract shape 'agent:{instance_id}'. The Nous Portal provisions this value at deploy time; check your Fly app's secrets or override with the value from the Portal admin UI.)r    r!   z/NousDashboardAuthProvider construction failed: zBdashboard-auth-nous: registered provider (client_id=%s, portal=%s))r   r   r   r   r   r&   r   r   r'    register_dashboard_auth_providerinfo)ctxr    r!   r   rc   s        r   registerr   Y  sR   . "$$I$&&J  	 	.0@AAA)) L	 L L L 	 	02BCCC,J
 
 
    RSRR02BCCC
 ((222
KKL    s   B 
C%C

C)r   r   r   r   )r   r   )r   r   )r   r"   )&r   
__future__r   r   rE   loggingr   rC   urllib.parserJ   typingr   r   r   r\   hermes_cli.dashboard_authr   r   r	   r
   r   r   	getLoggerr   r   r   r   __annotations__rI   r   r   r^   r   r   r   r   r   r   r   r   r   <module>r      s  C C CJ # " " " " "    				      & & & & & & & & & &                 
	8	$	$ 8 "       
"     # ? ? ? ?|
 |
 |
 |
 |
 5 |
 |
 |
H8 8 8 82" " " "$, , , ,"B B B B B Br   