
    )jq                    &   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	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ZdZdZd	Zd
Z da!de"d<   d!dZ#d"dZ$ G d de          Z%d#dZ&d$dZ'd%dZ(d&d Z)dS )'u  SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth.

A standards-compliant OpenID Connect Relying Party for the ``hermes dashboard``
OAuth gate. Unlike the bundled ``nous`` provider (which encodes Nous Portal's
bespoke contract — ``agent:{instance_id}`` client ids, a custom access-token
JWT, the ``x-nous-refresh-token`` header, an ``oauth_contract_version`` claim),
this provider speaks **plain OIDC** so it works against any conformant
self-hosted identity provider:

    Authentik · Keycloak · Zitadel · Authelia · Auth0 · Okta · Google · …

It is a pure drop-in plugin: it implements the five
:class:`~hermes_cli.dashboard_auth.DashboardAuthProvider` methods and touches
nothing in core auth/runtime/login. The HTTP round trip, cookies, CSRF
``state`` check and ``redirect_uri`` reconstruction are all owned by
``hermes_cli/dashboard_auth/routes.py``; this provider only:

  1. discovers the IDP's endpoints from ``{issuer}/.well-known/openid-configuration``,
  2. builds the ``/authorize`` URL with PKCE (S256),
  3. exchanges the authorization code for tokens at the discovered
     ``token_endpoint``,
  4. verifies the **ID token** (RS256/ES256) against the discovered
     ``jwks_uri`` with ``iss`` / ``aud`` pinned to the configured issuer /
     client id, and maps standard OIDC claims (``sub``, ``email``, ``name``)
     onto a :class:`~hermes_cli.dashboard_auth.Session`.

Why the ID token (not the access token)? OIDC guarantees the ID token is a
signed JWT carrying identity claims — that is its entire purpose. The access
token's format is opaque to the client per the spec; many IDPs issue random
opaque strings the client cannot verify locally. Verifying the ID token is the
only choice that is universally correct across self-hosted IDPs. (The ``nous``
provider verifies its *access* token because Nous Portal mints a custom JWT
access token with the dashboard claims baked in — a non-OIDC shortcut.)

Public PKCE clients only. Confidential clients (with a ``client_secret``) are
not yet supported — see the ``# TODO(confidential-client)`` seam in
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
client almost always register a public + PKCE client, which is the smaller,
simpler surface.

Configuration surfaces (env wins over config.yaml when set non-empty, so a
provisioned-but-not-populated secret can't shadow a valid config.yaml entry —
same precedence convention as the ``nous`` plugin)::

    # config.yaml — canonical surface
    dashboard:
      oauth:
        provider: self-hosted
        self_hosted:
          issuer: https://auth.example.com/application/o/hermes/   # required
          client_id: hermes-dashboard                              # required
          scopes: "openid profile email"                           # optional

    # Environment overrides (Docker/Fly secret injection)
    HERMES_DASHBOARD_OIDC_ISSUER
    HERMES_DASHBOARD_OIDC_CLIENT_ID
    HERMES_DASHBOARD_OIDC_SCOPES        # optional; defaults to "openid profile email"

Skip reasons: when the plugin loads but can't register (missing issuer /
client_id), it writes a human-readable reason to the module-level
:data:`LAST_SKIP_REASON` so the gate's fail-closed branch can surface a useful
operator error instead of the bare "no providers registered".
    )annotationsN)AnyDictOptional)DashboardAuthProviderInvalidCodeError
LoginStartProviderErrorRefreshExpiredErrorSessionzopenid profile email)RS256ES256RS384RS512ES384ES512g      $@i  i,   strLAST_SKIP_REASONrawbytesreturnc                t    t          j        |                               d                                          S )u6   Base64url-encode without ``=`` padding (RFC 7636 §4).   =)base64urlsafe_b64encoderstripdecode)r   s    R/home/wildlama/.hermes/hermes-agent/plugins/dashboard_auth/self_hosted/__init__.py_b64url_no_padr       s-    #C((//55<<>>>    urlfieldc                   t           j                            |           }|j        dk    r| S |j        dk    r|j        pddv r| S t          d| d|           )a{  Reject an endpoint URL that isn't HTTPS (loopback http is allowed).

    OAuth credentials (codes, tokens) flow over these URLs. We require HTTPS
    for everything except an explicit loopback host so a misconfigured issuer
    can't ship the authorization code / refresh token in cleartext. Returns
    the URL unchanged on success; raises :class:`ProviderError` otherwise.
    httpshttpr   )	localhost	127.0.0.1z::1zOIDC z. must be https:// (or http on localhost), got )urllibparseurlparseschemehostnamer
   )r"   r#   parseds      r   _require_https_or_loopbackr/      s|     \""3''F}
}FO$9r ? $ $
 

LLLSLL  r!   c                      e Zd ZdZdZdZedd2dZd3dZd4dZ	d5dZ
d6dZd7dZddd8d"Zd9d$Zd:d%Zd9d&Zd;d(Zd<d*Zd=d,Zd>d-Zd?d0Zd1S )@SelfHostedOIDCProviderzHGeneric self-hosted OpenID Connect provider (authorization-code + PKCE).zself-hostedzSelf-Hosted OIDC)scopesissuerr   	client_idr2   r   Nonec               R   |st          d          |st          d          |                    d          | _        t          | j        d           || _        |                                pt          | _        d | _        d| _	        t          j                    | _        d | _        d S )Nzissuer is requiredzclient_id is required/r3   r#   g        )
ValueErrorr   _issuerr/   
_client_idstrip_DEFAULT_SCOPES_scopes
_discovery_discovery_fetched_at	threadingLock_discovery_lock_jwks_client)selfr3   r4   r2   s       r   __init__zSelfHostedOIDCProvider.__init__   s      	31222 	64555 }}S))"4<x@@@@#||~~8
 26,/"(~//!%r!   redirect_urir	   c                  |                      |           |                                 }t          t          j        d                    }t          t          j        |                    d                                                              }t          t          j        d                    }d| j	        || j
        ||dd}|d          dt          j                            |           }d	d
| d| i}t          ||          S )N@   ascii    codeS256)response_typer4   rG   scopestatecode_challengecode_challenge_methodauthorization_endpoint?hermes_session_pkcezstate=z
;verifier=)redirect_urlcookie_payload)_validate_redirect_uri_get_discoveryr    secretstoken_byteshashlibsha256encodedigestr;   r>   r)   r*   	urlencoder	   )	rE   rG   discocode_verifierrQ   rP   paramsrV   rW   s	            r   start_loginz"SelfHostedOIDCProvider.start_login   s   ##L111##%%&w':2'>'>??'N=//8899@@BB
 
 w226677 $(\,%+
 
 -.QQ1G1G1O1OQQ 	 "#LE#L#L]#L#L
 |NSSSSr!   rL   rP   rb   r   c                   |}|                                  }d||| j        |d}|                     |d         |t                    S )Nauthorization_code)
grant_typerL   rG   r4   rb   token_endpoint)bad_request_exc)rY   r;   	_exchanger   )rE   rL   rP   rb   rG   _ra   datas           r   complete_loginz%SelfHostedOIDCProvider.complete_login   sd     ##%% /(*
 
 ~~"#T;K  
 
 	
r!   refresh_tokenc                   |st          d          |                                 }d| j        || j        d}|                     |d         |t           |          S )Nz#no refresh token present in sessionrn   )rg   r4   rn   rO   rh   )ri   previous_refresh_token)r   rY   r;   r>   rj   )rE   rn   ra   rl   s       r   refresh_sessionz&SelfHostedOIDCProvider.refresh_session   sz     	M%&KLLL##%% ** \
 
 ~~"#/#0	  
 
 	
r!   access_tokenOptional[Session]c                   	 |                      |          }n# t          $ r Y d S t          $ r  w xY w|                     |d|          S )Nr   id_tokenrn   claims)_verify_id_tokenr   r
   _session_from_tokens)rE   rr   rw   s      r   verify_sessionz%SelfHostedOIDCProvider.verify_session  sy    	**<88FF 	 	 	44 	 	 		 ((!F ) 
 
 	
s    
00c                  |sd S 	 |                                  }n# t          $ r Y d S w xY wt          |                    d          pd                                          }|sd S 	 t          j        ||d| j        dddit                     n2# t          $ r%}t                              d|           Y d }~nd }~ww xY wd S )	Nrevocation_endpointr   rn   )tokentoken_type_hintr4   Acceptapplication/jsonrl   headerstimeoutz-self-hosted OIDC: revoke failed (ignored): %s)rY   r
   r   getr<   httpxpostr;   _TOKEN_ENDPOINT_TIMEOUT_SEC	Exceptionloggerdebug)rE   rn   ra   endpointexcs        r   revoke_sessionz%SelfHostedOIDCProvider.revoke_session'  s     	4	''))EE 	 	 	44	uyy!677=2>>DDFF 	4	OJ*'6!% 
 "#563	 	 	 	 	  	O 	O 	OLLH#NNNNNNNN	Ots$    
))'(B 
B?B::B?r   )rp   rh   rl   Dict[str, str]ri   type[Exception]rp   c               |   	 t          j        ||ddit                    }n*# t           j        $ r}t	          d|           |d}~ww xY w|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          }t          |t                    r|s|pd}|                     |
||          S )uw  POST the token endpoint and turn the response into a Session.

        Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
        (refresh grant). ``bad_request_exc`` is raised on a 400 —
        ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
        for the refresh path — preserving the middleware's distinct handling.
        r   r   r   z!OIDC token endpoint unreachable: Ni  errorinvalid_requestzIDP rejected token request:    zOIDC token endpoint returned z: rv   u   OIDC token response missing id_token — ensure the 'openid' scope is configured and the client is allowed to receive an ID token.
token_typer   bearerzunexpected token_type=rn   ru   )r   r   r   RequestErrorr
   status_code_parse_json_bodyr   text
isinstancer   lowerrx   ry   )rE   rh   rl   ri   rp   responser   body
error_codepayloadrv   r   rw   rn   s                 r   rj   z SelfHostedOIDCProvider._exchangeD  s/   
	z!#563	  HH ! 	 	 	9C99 	
 3&&((22D'+<==J!/;z;;   3&&+0D + +=#&+ +  
 ''11;;z** 	z(C88 	   \26677==??
 	I*00 G G GHHH&&x00
  O44--- 	9] 	928bM((]6 ) 
 
 	
s   " A	AA	Dict[str, Any]c                   t          j                     }| j        || j        z
  t          k     r| j        S | j        5  t          j                     }| j        &|| j        z
  t          k     r| j        cddd           S |                                 }|| _        || _        d| _        |cddd           S # 1 swxY w Y   dS )z=Return the cached OIDC discovery document, fetching if stale.N)timer?   r@   _DISCOVERY_CACHE_TTL_SECrC   _fetch_discoveryrD   )rE   nowra   s      r   rY   z%SelfHostedOIDCProvider._get_discovery  s!   ikkO't115MMM?"! 	 	)++C+4559QQQ	 	 	 	 	 	 	 	 ))++E#DO),D& !%D	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	s   4B4=*B44B8;B8c                    | j          dS )Nz!/.well-known/openid-configuration)r:   )rE   s    r   _discovery_urlz%SelfHostedOIDCProvider._discovery_url  s    ,AAAAr!   c                   |                                  }	 t          j        |ddit                    }n*# t          j        $ r}t          d|           |d }~ww xY w|j        dk    rt          d|j         d|          |                     |          }|st          d          t          |                    d	d
          pd
          	                                }t          |                    dd
          pd
          	                                }t          |                    dd
          pd
          	                                }|r|r|st          d          t          |                    dd
          pd
          	                                }|r8|
                    d          | j        k    rt          d|d| j                  t          |d	           t          |d           t          |d           t          |                    dd
          pd
          	                                }	|p| j        ||||	dS )Nr   r   )r   r   zOIDC discovery unreachable: r   zOIDC discovery returned z for z'OIDC discovery returned a non-JSON bodyrS   r   rh   jwks_urizPOIDC discovery missing one of authorization_endpoint / token_endpoint / jwks_urir3   r7   z4OIDC discovery issuer mismatch: document advertises z but configured issuer is r8   r|   )r3   rS   rh   r   r|   )r   r   r   _DISCOVERY_TIMEOUT_SECr   r
   r   r   r   r<   r   r:   r/   )
rE   r"   r   r   r   rS   rh   r   advertised_issuerr|   s
             r   r   z'SelfHostedOIDCProvider._fetch_discovery  s   !!##	Oy!#56.  HH
 ! 	O 	O 	O Ds D DEE3N	O3&&M8+?MMcMM   ''11 	K IJJJ!$KK0"55;"
 "

%'' 	 W[[)92>>D"EEKKMMw{{:r228b99??AA% 	^ 	8 	,    Hb 9 9 ?R@@FFHH 	!2!9!9#!>!>$,!N!N$$$ $<$ $   	#"*B	
 	
 	
 	
 	#>9IJJJJ"8:>>>>!KK-r228b
 

%'' 	
 (74<&<, #6
 
 	
s   5 AAAr   c                    | j         8ddlm} |                                 } ||d         dt                    | _         | j         S )Nr   )PyJWKClientr   T)
cache_keyslifespan)rD   jwtr   rY   _JWKS_CACHE_SECONDS)rE   r   ra   s      r   _get_jwks_clientz'SelfHostedOIDCProvider._get_jwks_client  s`    $''''''''))E +j!,! ! !D
   r!   rv   c           
        dd l }|                                 }	 |                                                     |          }nE# |j        $ r}t          d|           |d }~wt          $ r}t          d|          |d }~ww xY w	 |                    ||j        t          t                    | j        |d         dg di          }n# |j        $ r}t          d|           |d }~w|j        $ r}d}	 |                    |d	d	d
          }d|                    d          d|                    d          d|d         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: r3   require)expiataudisssub)
algorithmsaudiencer3   optionszID token expired: r   F)verify_signature
verify_exp)r   z [token iss=r   z aud=r   z; expected iss=]zID token verification failed: )r   rY   r   get_signing_key_from_jwtPyJWKClientErrorr
   r   r   keylist_ALLOWED_ID_TOKEN_ALGSr;   ExpiredSignatureErrorr   InvalidTokenErrorr   )	rE   rv   r   ra   signing_keyr   rw   details
unverifieds	            r   rx   z'SelfHostedOIDCProvider._verify_id_token  sF   


##%%	I//11JJ KK # 	G 	G 	G <s < <==3F 	I 	I 	I >s > >??SH	I!	ZZ 677X"$G$G$GH    FF ( 	H 	H 	H"#=#=#=>>CG$ 	 	 	
 G ZZ16eLL (  

0:>>%#8#8 0 0%>>%000 0$)(O0 0  ?0 0 0     ??g?? '	. sm   'A 
BAB,A??BAC
 

E<C''E<4E77AEE7
EE7EE77E<rw   c                  t          |                    dd                    }|st          d          t          |                    dd          pd          }t          |                    d          p-|                    d          p|                    d          p|pd          }|                    d          p|                    d	          pd}|sK|                    d
          }t          |t                    r!|rd                    d |D                       }t          |pd          }t          ||||| j        t          |d                   ||          S )u\  Map verified OIDC claims onto a Session.

        The verified ID token is stored in ``Session.access_token`` so the
        per-request ``verify_session`` re-verifies a real JWT. The opaque
        OAuth access token is intentionally NOT stored — Hermes does not call
        any resource API with it; the dashboard only needs identity.
        r   r   z&ID token missing 'sub' (user_id) claimemailnamepreferred_usernamenicknameorg_idorganizationgroups,c              3  4   K   | ]}t          |          V  d S )N)r   ).0gs     r   	<genexpr>z>SelfHostedOIDCProvider._session_from_tokens.<locals>.<genexpr>C  s(      !9!9Q#a&&!9!9!9!9!9!9r!   r   )user_idr   display_namer   provider
expires_atrr   rn   )	r   r   r
   r   r   joinr   r   int)	rE   rv   rn   rw   r   r   r   r   r   s	            r   ry   z+SelfHostedOIDCProvider._session_from_tokens!  s{    fjj++,, 	J HIIIFJJw++1r22JJv zz.//zz*%%  
 
 H%%IN)C)CIr 	:ZZ))F&$'' :F :!9!9&!9!9!999V\r""%Y6%=))!'	
 	
 	
 		
r!   c                ,   t           j                            |          }|j        dvrt	          d|          |j        dk    r|j        dvrt	          d|          |j        r|j                            d          st	          d|          dS )	zFast-fail obviously-broken redirect_uris before bouncing to the IDP.

        The IDP's own allowlist is authoritative; this just catches the common
        operator-error case with a clear message. Mirrors the nous provider.
        )r%   r&   z"redirect_uri must be http(s), got r&   )r'   r(   z?redirect_uri may only use http:// for localhost/127.0.0.1, got z/auth/callbackz6redirect_uri path must end with '/auth/callback', got N)r)   r*   r+   r,   r
   r-   pathendswith)rE   rG   r.   s      r   rX   z-SelfHostedOIDCProvider._validate_redirect_uriQ  s     &&|44= 111E\EE   =F""v ?
 (
 (
  (#( (   { 	&+"6"67G"H"H 	(#( (  	 	r!   r   httpx.Responsec                    |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   r   )r   r   
startswithjsonr9   r   dict)rE   r   ctyper   s       r   r   z'SelfHostedOIDCProvider._parse_json_bodyj  s     $$^R88 233 	I	==??DD 	 	 	III	!$--5tt25s   A	 	AAN)r3   r   r4   r   r2   r   r   r5   )rG   r   r   r	   )
rL   r   rP   r   rb   r   rG   r   r   r   )rn   r   r   r   )rr   r   r   rs   )rn   r   r   r5   )
rh   r   rl   r   ri   r   rp   r   r   r   )r   r   )r   r   )r   r   )rv   r   r   r   )rv   r   rn   r   rw   r   r   r   )rG   r   r   r5   )r   r   r   r   )__name__
__module____qualname____doc__r   r   r=   rF   rd   rm   rq   rz   r   rj   rY   r   r   r   rx   ry   rX   r    r!   r   r1   r1      s       RRD%L && & & & & &>T T T T:
 
 
 
4
 
 
 
*
 
 
 
&   F ')@
 @
 @
 @
 @
 @
H   .B B B B9
 9
 9
 9
z
! 
! 
! 
!1 1 1 1j.
 .
 .
 .
`   26 6 6 6 6 6r!   r1   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, or ``{}``.

    Robust to load_config() raising, the ``dashboard`` key being absent or
    non-dict, and ``oauth`` being present but not a dict — each falls through
    to ``{}`` so callers can rely on ``.get(...)``.
    r   )cfg_getload_configz[dashboard-auth-self-hosted: load_config() raised %s; falling back to env-only configurationN	dashboardoauth)default)hermes_cli.configr   r   r   r   r   r   r   )r   r   cfgr   sections        r   _load_config_oauth_sectionr   z  s    
::::::::kmm   5	
 	
 	

 						 gc;>>>G $//777R7s    
AAAAoauth_sectionc                ^    |                      d          }t          |t                    r|ni S )z@Return the ``dashboard.oauth.self_hosted`` sub-block, or ``{}``.self_hosted)r   r   r   )r   r   s     r   _oidc_subsectionr     s.    


M
*
*CS$''/33R/r!   env_var	cfg_valuer   c                    t           j                            | d                                          }|r|S t	          |pd                                          S )zenv-wins-config with empty-is-unset precedence.

    1. ``env_var`` when non-empty after strip (an empty provisioned secret
       must not shadow a valid config.yaml entry).
    2. ``cfg_value`` from config.yaml.
    3. Empty string.
    r   )osenvironr   r<   r   )r   r   envs      r   _resolve_settingr     sP     *.."
%
%
+
+
-
-C
 
yB%%'''r!   r5   c                   da t                      }t          |          }t          d|                    d                    }t          d|                    d                    }t          d|                    d                    pt
          }|r|sEdt          |          d	t          |          d
a t                              dt                      dS 	 t          |||          }nD# t          t          f$ 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 :class:`SelfHostedOIDCProvider` only when both an issuer and a
    client_id are configured (via ``HERMES_DASHBOARD_OIDC_*`` env vars or the
    ``dashboard.oauth.self_hosted`` block in config.yaml). Operator-owned
    loopback / ``--insecure`` dashboards leave these unset, so the plugin is a
    no-op for them.

    On skip, writes a reason to :data:`LAST_SKIP_REASON` that names BOTH
    configuration surfaces so operators don't guess wrong about which to set.
    r   HERMES_DASHBOARD_OIDC_ISSUERr3   HERMES_DASHBOARD_OIDC_CLIENT_IDr4   HERMES_DASHBOARD_OIDC_SCOPESr2   u:  Self-hosted OIDC dashboard auth is not configured. Set both an issuer and a client_id — either as env vars (HERMES_DASHBOARD_OIDC_ISSUER + HERMES_DASHBOARD_OIDC_CLIENT_ID) or under dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml — or pass --insecure to skip the OAuth gate entirely. (issuer set: z; client_id set: )zdashboard-auth-self-hosted: %sN)r3   r4   r2   z,SelfHostedOIDCProvider construction failed: zTdashboard-auth-self-hosted: registered provider (issuer=%s, client_id=%s, scopes=%r))r   r   r   r   r   r=   boolr   r   r1   r9   r
   warning register_dashboard_auth_providerinfo)ctxr   oidc_cfgr3   r4   r2   r   r   s           r   registerr    s    .00M..H&X(>(> F !)8<<+D+D I 	7h9O9OPP 	 
    F||||T)____. 	 	57GHHH	)Yv
 
 
 &   @3@@ 	 	79IJJJ ((222
KK	/    s   C- -D.>%D))D.)r   r   r   r   )r"   r   r#   r   r   r   )r   r   )r   r   r   r   )r   r   r   r   r   r   )r   r5   )*r   
__future__r   r   r\   loggingr   rZ   rA   r   urllib.parser)   typingr   r   r   r   hermes_cli.dashboard_authr   r   r	   r
   r   r   	getLoggerr   r   r=   r   r   r   r   r   r   __annotations__r    r/   r1   r   r   r   r  r   r!   r   <module>r     s  > > >@ # " " " " "    				           & & & & & & & & & &                 
	8	$	$ ) P   " 
           ? ? ? ?
   6O6 O6 O6 O6 O62 O6 O6 O6n8 8 8 8,0 0 0 0( ( ( (< < < < < <r!   