Coverage for server / authentication / cookie_session.py: 89%

44 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1""" 

2BFF httpOnly-Cookie Session Helpers — Teacher/Student API (port 8000) 

3 

4Phase 1 of ADR-001: dual-mode auth. The login endpoint calls 

5set_auth_cookies() to write the two session cookies while still returning 

6the token in the JSON body. Auth0Bearer reads the cookie as an alternative 

7to the Authorization header; cookie-based requests on unsafe HTTP methods 

8are CSRF-verified via the double-submit pattern. 

9 

10Cookie attributes (committed in ADR-001): 

11 access_token HttpOnly; Secure*; SameSite=None; Path=/; Max-Age=<ttl> 

12 csrf_token Secure*; SameSite=None; Path=/ (NOT HttpOnly — SPA reads it) 

13 

14* Secure is configurable via COOKIE_SECURE env var (default "true"). 

15 Set COOKIE_SECURE=false for local http:// testing only. 

16 

17Developer: Allan Ninal 

18""" 

19 

20import logging 

21import os 

22import secrets 

23 

24from fastapi import Request, Response, status 

25from fastapi import HTTPException 

26 

27logger = logging.getLogger(__name__) 

28 

29# --------------------------------------------------------------------------- 

30# Internal helpers 

31# --------------------------------------------------------------------------- 

32 

33_COOKIE_SECURE_ENV = os.getenv("COOKIE_SECURE", "true").lower() 

34_COOKIE_SECURE: bool = _COOKIE_SECURE_ENV not in ("false", "0", "no") 

35 

36_SAMESITE = "none" 

37_ACCESS_TOKEN_COOKIE = "access_token" 

38_CSRF_TOKEN_COOKIE = "csrf_token" 

39_CSRF_HEADER = "x-csrf-token" 

40 

41# Phase 4 (ADR-001): the refresh token is scoped to the auth routes so the 

42# browser only ever sends it to /v1/auth/refresh and /v1/auth/logout — never to 

43# the rest of the API. Refresh tokens outlive the 24h access token; cap at 14d. 

44_REFRESH_TOKEN_COOKIE = "refresh_token" 

45_REFRESH_COOKIE_PATH = "/v1/auth" 

46_REFRESH_MAX_AGE = 14 * 24 * 3600 

47 

48# Unsafe HTTP methods that require a CSRF header when using cookie auth. 

49_UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) 

50 

51 

52def _cookie_secure() -> bool: 

53 """Re-read COOKIE_SECURE at call time so tests can patch os.environ.""" 

54 val = os.getenv("COOKIE_SECURE", "true").lower() 

55 return val not in ("false", "0", "no") 

56 

57 

58# --------------------------------------------------------------------------- 

59# Public API 

60# --------------------------------------------------------------------------- 

61 

62def set_auth_cookies( 

63 response: Response, 

64 access_token: str, 

65 expires_in: int | None, 

66 csrf_token: str | None = None, 

67) -> str: 

68 """ 

69 Write access_token (HttpOnly) and csrf_token (readable) cookies onto 

70 *response*. Returns the generated csrf_token value so callers can log it 

71 if needed. 

72 

73 Called from the login route BEFORE the existing ``return {access_token, …}`` 

74 so the body token stays in Phase 1. 

75 

76 These are **session cookies** (no Max-Age/Expires): the browser drops them 

77 when it closes, so closing the browser without logging out ends the 

78 session. The access token's own JWT ``exp`` still bounds its lifetime 

79 within an open session; the refresh cookie (also session-scoped) renews it 

80 while the browser stays open. ``expires_in`` is accepted for signature 

81 compatibility but intentionally no longer pinned onto the cookie. 

82 

83 ``csrf_token`` is generated when omitted, which is what every caller wants. 

84 It is passed in for exactly one case: a refresh that JOINED an in-flight 

85 rotation (see ``server/utilities/refresh_single_flight.py``) must reuse the 

86 winner's CSRF value. If it minted its own, the two racing tabs would hold 

87 different CSRF tokens while the browser kept only the last cookie written, 

88 and whichever tab lost that race would fail its next unsafe request. 

89 """ 

90 secure = _cookie_secure() 

91 

92 csrf_token = csrf_token or secrets.token_urlsafe(32) 

93 

94 response.set_cookie( 

95 key=_ACCESS_TOKEN_COOKIE, 

96 value=access_token, 

97 httponly=True, 

98 secure=secure, 

99 samesite=_SAMESITE, 

100 path="/", 

101 ) 

102 response.set_cookie( 

103 key=_CSRF_TOKEN_COOKIE, 

104 value=csrf_token, 

105 httponly=False, 

106 secure=secure, 

107 samesite=_SAMESITE, 

108 path="/", 

109 ) 

110 

111 logger.debug("Auth session cookies set (secure=%s, session-scoped)", secure) 

112 return csrf_token 

113 

114 

115def set_refresh_cookie(response: Response, refresh_token: str) -> None: 

116 """Write the httpOnly refresh-token cookie, scoped to the auth routes 

117 (Path=/v1/auth) so it is never sent to the rest of the API. 

118 

119 Session-scoped (no Max-Age): the browser drops it on close, so a closed 

120 browser cannot silently revive the session via /v1/auth/refresh, and a 

121 logged-out user has no surviving refresh token to auto-login with.""" 

122 response.set_cookie( 

123 key=_REFRESH_TOKEN_COOKIE, 

124 value=refresh_token, 

125 httponly=True, 

126 secure=_cookie_secure(), 

127 samesite=_SAMESITE, 

128 path=_REFRESH_COOKIE_PATH, 

129 ) 

130 

131 

132def get_refresh_token(request: Request) -> str: 

133 """Return the refresh-token cookie value, or "" if absent.""" 

134 return request.cookies.get(_REFRESH_TOKEN_COOKIE, "") 

135 

136 

137def clear_auth_cookies(response: Response) -> None: 

138 """Delete all three session cookies (access, csrf, refresh).""" 

139 secure = _cookie_secure() 

140 

141 response.delete_cookie( 

142 key=_ACCESS_TOKEN_COOKIE, 

143 path="/", 

144 secure=secure, 

145 samesite=_SAMESITE, 

146 httponly=True, 

147 ) 

148 response.delete_cookie( 

149 key=_CSRF_TOKEN_COOKIE, 

150 path="/", 

151 secure=secure, 

152 samesite=_SAMESITE, 

153 httponly=False, 

154 ) 

155 response.delete_cookie( 

156 key=_REFRESH_TOKEN_COOKIE, 

157 path=_REFRESH_COOKIE_PATH, 

158 secure=secure, 

159 samesite=_SAMESITE, 

160 httponly=True, 

161 ) 

162 logger.debug("Auth cookies cleared") 

163 

164 

165def validate_csrf(request: Request) -> bool: 

166 """ 

167 Double-submit CSRF check. 

168 

169 Returns True — safe method, or CSRF header matches cookie. 

170 Returns False — unsafe method and header is missing/mismatched. 

171 

172 Callers should raise HTTP 403 when this returns False. 

173 Note: GET/HEAD/OPTIONS are always safe (the response is not readable 

174 cross-origin thanks to CORS), so we only check unsafe methods. 

175 """ 

176 if request.method.upper() not in _UNSAFE_METHODS: 

177 return True 

178 

179 cookie_value = request.cookies.get(_CSRF_TOKEN_COOKIE, "") 

180 header_value = request.headers.get(_CSRF_HEADER, "") 

181 

182 if not cookie_value or not header_value: 

183 return False 

184 

185 # Use secrets.compare_digest to avoid timing attacks. 

186 return secrets.compare_digest(cookie_value, header_value)