2026-09-11 00:31:03 +02:00
""" Browser login and protected review routes, enabled by owner configuration. """
2026-09-10 22:05:17 +02:00
from __future__ import annotations
import html
from http . cookies import CookieError , SimpleCookie
import json
import os
import secrets
2026-09-11 00:31:03 +02:00
import re
import sqlite3
2026-09-10 22:05:17 +02:00
from urllib . parse import parse_qs
from . oidc import KeyCapeLogin , LoginError , ORIGIN
2026-09-11 00:31:03 +02:00
from . approval_client import ApprovalEngineError
from . disposition import DispositionRefused , Verb
from . policy import PolicyError
from . provenance import HumanControlNotDischargeable
from . review import ReviewError
from . store import Conflict , EvidenceUnavailable , StoreError
from . ui import STYLES , document , error_page , review_page
2026-09-10 22:05:17 +02:00
FLOW_COOKIE = " __Host-infd-flow "
SESSION_COOKIE = " __Host-infd-session "
def _cookie ( name : str , value : str , seconds : int ) - > tuple [ str , str ] :
return " Set-Cookie " , f " { name } = { value } ; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age= { seconds } "
2026-09-11 00:31:03 +02:00
def _one ( query : str , * , limit = 8192 , fields = 16 ) - > dict [ str , str ] :
if len ( query ) > limit :
2026-09-10 22:05:17 +02:00
raise ValueError ( " request too large " )
2026-09-11 00:31:03 +02:00
values = parse_qs ( query , keep_blank_values = True , max_num_fields = fields , errors = " strict " )
2026-09-10 22:05:17 +02:00
if any ( len ( v ) != 1 for v in values . values ( ) ) :
raise ValueError ( " duplicate parameter " )
return { k : v [ 0 ] for k , v in values . items ( ) }
class App :
2026-09-11 00:31:03 +02:00
def __init__ ( self , login : KeyCapeLogin , review = None , * , readiness = lambda : False ) :
2026-09-10 22:05:17 +02:00
self . login = login
2026-09-11 00:31:03 +02:00
self . review , self . readiness = review , readiness
2026-09-10 22:05:17 +02:00
def __call__ ( self , environ , start_response ) :
2026-09-11 00:31:03 +02:00
path = environ . get ( " PATH_INFO " , " / " )
# Chromium sends Origin: null for a form under no-referrer. Review
# pages need same-origin so legitimate POSTs satisfy the exact-origin
# CSRF check. Authentication URLs and downloads never send a referrer.
referrer_policy = " no-referrer " if path . startswith ( " /auth/ " ) or " /packet/ " in path else " same-origin "
2026-09-10 22:05:17 +02:00
headers = [
( " Cache-Control " , " no-store " ) , ( " Pragma " , " no-cache " ) ,
2026-09-11 00:31:03 +02:00
( " Referrer-Policy " , referrer_policy ) , ( " X-Content-Type-Options " , " nosniff " ) ,
( " Content-Security-Policy " , " default-src ' none ' ; style-src ' self ' ; form-action ' self ' ; frame-ancestors ' none ' ; base-uri ' none ' " ) ,
2026-09-10 22:05:17 +02:00
( " Strict-Transport-Security " , " max-age=31536000 " ) ,
]
try :
cookies = SimpleCookie ( )
cookies . load ( environ . get ( " HTTP_COOKIE " , " " ) )
sid = cookies [ SESSION_COOKIE ] . value if SESSION_COOKIE in cookies else " "
browser = cookies [ FLOW_COOKIE ] . value if FLOW_COOKIE in cookies else " "
if not sid . isascii ( ) or not browser . isascii ( ) :
raise ValueError ( " invalid cookie " )
status , body , content_type , extra = self . dispatch ( environ , sid , browser )
headers . extend ( extra )
except LoginError :
status , body , content_type = 401 , " Sign-in could not be completed. Start a new sign-in. " , " text/plain "
headers . append ( _cookie ( FLOW_COOKIE , " " , 0 ) )
except ( ValueError , CookieError , UnicodeError ) :
status , body , content_type = 400 , " Invalid request. " , " text/plain "
2026-09-11 00:31:03 +02:00
except ReviewError as exc :
messages = {
" session_expired " : " Your session expired. Sign in again to continue. " ,
" invalid_form " : " This form could not be verified. Reopen the review before submitting again. " ,
" policy_denied " : " The permission service refused this review action. " ,
" wrong_recipient " : " This review is addressed to another person. " ,
" stale_presentation " : " The memo changed. Open its current version before taking an action. " ,
" binding_changed " : " The approval no longer matches this memo. A revised review is required. " ,
" renderer_changed " : " This memo names an earlier review interface. A new memo version is required before showing it here. " ,
" unsupported_review_profile " : " This review interface supports English organizational approvals. The memo requires a different review profile. " ,
" existing_entry_unlinked " : " An approval entry already exists, but this review cannot prove its original presentation. Operator recovery is required; no new entry was submitted. " ,
" act_unavailable " : " This approval is no longer open for a new entry. " ,
}
status , body , content_type = exc . status , error_page ( messages . get ( exc . code ,
" The review could not complete its required checks. Reload after the service is available. " ) ) , " text/html "
except DispositionRefused as exc :
status , body , content_type = 409 , error_page ( {
" G_ACK " : " Record all required highlight acknowledgments before accepting or declining. " ,
" G_REASONS " : " Choose a reason when returning this memo. " ,
" G_PRES " : " The memo changed. Open its current version before taking an action. " ,
} . get ( exc . guard , " This action is not available for the current review. " ) ) , " text/html "
except HumanControlNotDischargeable :
status , body , content_type = 403 , error_page ( " A verified human session is required. " ) , " text/html "
except EvidenceUnavailable :
status , body , content_type = 404 , error_page ( " The requested review or its evidence is unavailable. " ) , " text/html "
except Conflict :
status , body , content_type = 409 , error_page ( " The review changed or already has a submission. Reload the original record. " ) , " text/html "
except ( PolicyError , ApprovalEngineError , StoreError , sqlite3 . Error ) :
status , body , content_type = 503 , error_page ( " The required service or evidence store is unavailable. A submission may be unresolved; reopen the original review before taking another action. " ) , " text/html "
raw = body if isinstance ( body , bytes ) else body . encode ( )
headers + = [ ( " Content-Type " , content_type + ( " ; charset=utf-8 " if content_type != " application/octet-stream " else " " ) ) , ( " Content-Length " , str ( len ( raw ) ) ) ]
2026-09-10 22:05:17 +02:00
start_response ( f " { status } { ' OK ' if status < 400 else ' ERROR ' } " , headers )
return [ raw ]
def dispatch ( self , environ , sid , browser ) :
method , path = environ . get ( " REQUEST_METHOD " , " GET " ) , environ . get ( " PATH_INFO " , " / " )
if method == " GET " and path == " /healthz " :
return 200 , ' { " status " : " ok " } ' , " application/json " , [ ]
if method == " GET " and path == " /readyz " :
2026-09-11 00:31:03 +02:00
if self . review is not None and self . readiness ( ) :
return 200 , ' { " status " : " ready " } ' , " application/json " , [ ]
2026-09-10 22:05:17 +02:00
return 503 , json . dumps ( { " status " : " incomplete " , " reason " : " approval_path_not_connected " } ) , " application/json " , [ ]
2026-09-11 00:31:03 +02:00
if method == " GET " and path == " /assets/review.css " :
return 200 , STYLES , " text/css " , [ ]
2026-09-10 22:05:17 +02:00
if method == " GET " and path == " /auth/start " :
url , browser = self . login . start ( )
return 303 , " " , " text/plain " , [ ( " Location " , url ) , _cookie ( FLOW_COOKIE , browser , 300 ) ]
if method == " GET " and path == " /auth/callback " :
params = _one ( environ . get ( " QUERY_STRING " , " " ) )
# Even an issuer refusal consumes state. Never echo error_description.
new_sid = self . login . finish ( params . get ( " state " , " " ) , browser ,
" " if " error " in params else params . get ( " code " , " " ) )
self . login . logout ( sid )
return 303 , " " , " text/plain " , [ ( " Location " , " / " ) , _cookie ( FLOW_COOKIE , " " , 0 ) ,
_cookie ( SESSION_COOKIE , new_sid , 900 ) ]
session = self . login . session ( sid )
2026-09-11 00:31:03 +02:00
if self . review is not None and ( path == " /review " or path . startswith ( " /presentations/ " ) ) :
if session is None :
raise ReviewError ( 401 , " session_expired " )
if method == " GET " and path == " /review " :
params = _one ( environ . get ( " QUERY_STRING " , " " ) )
if set ( params ) != { " memo_id " } or not re . fullmatch ( r " [A-Za-z0-9][A-Za-z0-9._:-] { 0,255} " , params [ " memo_id " ] ) :
raise ValueError ( " invalid memo id " )
page = self . review . open ( session , params [ " memo_id " ] )
return 200 , review_page ( page , session ) , " text/html " , [ ]
match = re . fullmatch ( r " /presentations/(pres-[a-f0-9-] {36} )(?:/(ack|act|packet/([0-9] { 1,3})))? " , path )
if not match :
return 404 , " Not found. " , " text/plain " , [ ]
presentation_id , route , index = match . groups ( )
if method == " GET " and ( route is None or index is not None ) :
page = self . review . load ( session , presentation_id )
if index is not None :
number = int ( index )
if number > = len ( page . memo . packet ) :
return 404 , " Not found. " , " text/plain " , [ ]
item = page . memo . packet [ number ]
return 200 , page . documents [ item . item_id ] , " application/octet-stream " , [
( " Content-Disposition " , f ' attachment; filename= " review-document- { number + 1 } .bin " ' ) ]
return 200 , review_page ( page , session ) , " text/html " , [ ]
if method == " POST " and route in ( " ack " , " act " ) :
params = self . _form ( environ , session )
if route == " ack " :
if any ( k != " csrf " and not re . fullmatch ( r " h[0-9] { 1,3} " , k ) for k in params ) :
raise ValueError ( " unexpected acknowledgment field " )
self . review . acknowledge ( session , presentation_id , [ v for k , v in params . items ( ) if k != " csrf " ] )
target = presentation_id
else :
if set ( params ) - { " csrf " , " verb " , " operation_id " , " reason " , " note " } :
raise ValueError ( " unexpected action field " )
if not re . fullmatch ( r " [a-f0-9-] {36} " , params . get ( " operation_id " , " " ) ) :
raise ValueError ( " invalid operation id " )
if params . get ( " verb " ) == " accept " and not self . readiness ( ) :
raise ReviewError ( 503 , " audit_delivery_unavailable " )
target = self . review . act ( session , presentation_id , Verb ( params . get ( " verb " ) ) ,
operation_id = params [ " operation_id " ] , reasons = ( params [ " reason " ] , ) if params . get ( " reason " ) else ( ) ,
note = params . get ( " note " ) or None )
return 303 , " " , " text/plain " , [ ( " Location " , " /presentations/ " + target ) ]
return 405 , " Method not allowed. " , " text/plain " , [ ( " Allow " , " POST " if route in ( " ack " , " act " ) else " GET " ) ]
2026-09-10 22:05:17 +02:00
if method == " POST " and path == " /auth/logout " :
if environ . get ( " HTTP_ORIGIN " ) != ORIGIN or not session :
return 403 , " Invalid sign-out request. " , " text/plain " , [ ]
length = int ( environ . get ( " CONTENT_LENGTH " ) or 0 )
if not 0 < length < = 2048 or environ . get ( " CONTENT_TYPE " , " " ) . split ( " ; " ) [ 0 ] != " application/x-www-form-urlencoded " :
raise ValueError ( " invalid body " )
raw = environ [ " wsgi.input " ] . read ( length )
if len ( raw ) != length :
raise ValueError ( " truncated body " )
csrf = _one ( raw . decode ( ) ) . get ( " csrf " , " " )
if not csrf . isascii ( ) or not secrets . compare_digest ( csrf , session . csrf ) :
return 403 , " Invalid sign-out request. " , " text/plain " , [ ]
self . login . logout ( sid )
return 303 , " " , " text/plain " , [ ( " Location " , " / " ) , _cookie ( SESSION_COOKIE , " " , 0 ) ]
if method == " GET " and path == " / " :
if session :
content = ( f " <p>Signed in as { html . escape ( session . subject ) } .</p> "
2026-09-11 00:31:03 +02:00
+ ( ' <h1>Open a decision review</h1><p>Enter the memo identifier supplied with your review request.</p> '
' <form method= " get " action= " /review " ><label>Memo identifier <input name= " memo_id " required maxlength= " 256 " ></label> '
' <button type= " submit " >Open review</button></form> ' if self . review is not None else
' <p>Decision review is being prepared. No approval has been recorded.</p> ' ) +
2026-09-10 22:05:17 +02:00
' <form method= " post " action= " /auth/logout " > '
f ' <input type= " hidden " name= " csrf " value= " { session . csrf } " > '
' <button type= " submit " >Sign out</button></form> ' )
else :
content = ' <p><a href= " /auth/start " >Sign in with KeyCape</a></p> '
2026-09-11 00:31:03 +02:00
return 200 , document ( " Informed Decision " , content , session . subject if session else None ) , " text/html " , [ ]
2026-09-10 22:05:17 +02:00
return 404 , " Not found. " , " text/plain " , [ ]
2026-09-11 00:31:03 +02:00
def _form ( self , environ , session ) :
if environ . get ( " HTTP_ORIGIN " ) != ORIGIN :
raise ReviewError ( 403 , " invalid_form " )
length = int ( environ . get ( " CONTENT_LENGTH " ) or 0 )
if not 0 < length < = 65536 or environ . get ( " CONTENT_TYPE " , " " ) . split ( " ; " ) [ 0 ] != " application/x-www-form-urlencoded " :
raise ValueError ( " invalid body " )
raw = environ [ " wsgi.input " ] . read ( length )
if len ( raw ) != length :
raise ValueError ( " truncated body " )
params = _one ( raw . decode ( ) , limit = 65536 , fields = 256 )
csrf = params . get ( " csrf " , " " )
if not csrf . isascii ( ) or not secrets . compare_digest ( csrf , session . csrf ) :
raise ReviewError ( 403 , " invalid_form " )
return params
2026-09-10 22:05:17 +02:00
def main ( ) :
from waitress import serve
# Waitress does not log request targets; a proxy must also omit callback
# query strings and cookies. No debug traceback middleware belongs here.
2026-09-11 00:31:03 +02:00
login = KeyCapeLogin ( os . environ [ " INFD_KEYCAPE_ISSUER " ] )
runtime = None
if os . environ . get ( " INFD_REVIEW_CONFIG " ) :
from . runtime import Runtime
runtime = Runtime . from_file ( os . environ [ " INFD_REVIEW_CONFIG " ] )
runtime . pump . start ( )
app = App ( login , runtime . controller if runtime else None ,
readiness = runtime . pump . ready if runtime else lambda : False )
try :
serve ( app , host = " 127.0.0.1 " , port = 8080 , threads = 4 )
finally :
if runtime :
runtime . pump . stop ( )