Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# CHANGELOG

## v24.32

### Pre-releases
- v24.32-alpha1

### Fix
- Session expiration in userinfo must match access token expiration (#412, `v24.32-alpha1`)

---


## v24.29

### Pre-releases
Expand Down
3 changes: 3 additions & 0 deletions seacatauth/contextvars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from contextvars import ContextVar

AccessIps = ContextVar("request_access_ips", default=None)
217 changes: 31 additions & 186 deletions seacatauth/cookie/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from .. import exceptions, AuditLogger
from .. import generic
from ..contextvars import AccessIps
from ..openidconnect.utils import TokenRequestErrorResponseCode

#
Expand Down Expand Up @@ -93,13 +94,13 @@ def __init__(self, app, cookie_svc, session_svc, credentials_svc):
web_app = app.WebContainer.WebApp
web_app.router.add_post("/nginx/introspect/cookie", self.nginx)
web_app.router.add_post("/nginx/introspect/cookie/anonymous", self.nginx_anonymous)
web_app.router.add_get("/cookie/entry", self.bouncer_get)
web_app.router.add_post("/cookie/entry", self.bouncer_post)
web_app.router.add_get("/cookie/entry", self.cookie_get)
web_app.router.add_post("/cookie/entry", self.cookie_post)

# Public endpoints
web_app_public = app.PublicWebContainer.WebApp
web_app_public.router.add_get("/cookie/entry", self.bouncer_get)
web_app_public.router.add_post("/cookie/entry", self.bouncer_post)
web_app_public.router.add_get("/cookie/entry", self.cookie_get)
web_app_public.router.add_post("/cookie/entry", self.cookie_post)

# TODO: Insecure, back-compat only - remove after 2024-03-31
if asab.Config.getboolean("seacatauth:introspection", "_enable_insecure_legacy_endpoints", fallback=False):
Expand Down Expand Up @@ -288,7 +289,7 @@ async def nginx_anonymous(self, request):
return response


async def bouncer_get(self, request):
async def cookie_get(self, request):
"""
Exchange authorization code for cookie and redirect to specified redirect URI.

Expand Down Expand Up @@ -320,10 +321,10 @@ async def bouncer_get(self, request):
type: string
"""
params = request.query
return await self._bouncer(request, params)
return await self._cookie(request, params)


async def bouncer_post(self, request):
async def cookie_post(self, request):
"""
Exchange authorization code for cookie and redirect to specified redirect URI.

Expand Down Expand Up @@ -355,132 +356,29 @@ async def bouncer_post(self, request):
- redirect_uri
"""
params = await request.post()
return await self._bouncer(request, params)
return await self._cookie(request, params)


async def _bouncer(self, request, parameters):
async def _cookie(self, request, parameters):
"""
Exchange authorization code for cookie and redirect to specified redirect URI.
"""
client_svc = self.App.get_service("seacatauth.ClientService")
from_ip = generic.get_request_access_ips(request)

client_id = parameters.get("client_id")
if client_id is None:
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: No 'client_id' in request query",
struct_data={"from_ip": from_ip}
)
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400)
try:
client = await client_svc.get(client_id)
except KeyError:
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: Client not found",
struct_data={"from_ip": from_ip, "client_id": client_id}
)
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.InvalidClient}, status=400)

grant_type = parameters.get("grant_type")
if grant_type != "authorization_code":
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: Unsupported grant type",
struct_data={
"client_id": client_id,
"from_ip": from_ip,
"grant_type": grant_type,
}
)
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.UnsupportedGrantType}, status=400)

# Use the code to get session ID
authorization_code = parameters.get("code")
if not authorization_code:
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: No 'code' in request query",
struct_data={
"client_id": client_id,
"from_ip": from_ip,
}
)
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400)
try:
session = await self.CookieService.get_session_by_authorization_code(authorization_code)
except KeyError:
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: Invalid or expired authorization code",
struct_data={
"client_id": client_id,
"from_ip": from_ip,
}
)
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.InvalidGrant}, status=400)

# Determine the destination URI
if "redirect_uri" in parameters:
# Use the redirect URI from request query
# TODO: Optionally validate the URI against client["redirect_uris"]
# and check if it is the same as in the authorization request
redirect_uri = parameters["redirect_uri"]
else:
# Fallback to client URI or Auth UI
redirect_uri = client.get("client_uri") or self.CookieService.AuthWebUiBaseUrl.rstrip("/")

# Set track ID if not set yet
if session.TrackId is None:
session = await self.SessionService.inherit_track_id_from_root(session)
if session.TrackId is None:
# Obtain the old session by request cookie or access token
try:
old_session = await self.CookieService.get_session_by_request_cookie(
request, session.OAuth2.ClientId)
except exceptions.SessionNotFoundError:
old_session = None
except exceptions.NoCookieError:
old_session = None

token_value = generic.get_bearer_token_value(request)
if old_session is None and token_value is not None:
try:
old_session = await self.CookieService.OpenIdConnectService.get_session_by_access_token(token_value)
except exceptions.SessionNotFoundError:
# Invalid access token should result in error
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: Track ID transfer failed because of invalid Authorization header",
struct_data={
"cid": session.Credentials.Id,
"sid": session.Id,
"client_id": session.OAuth2.ClientId,
"from_ip": from_ip,
"redirect_uri": redirect_uri
}
)
return aiohttp.web.HTTPBadRequest()
try:
session = await self.SessionService.inherit_or_generate_new_track_id(session, old_session)
except ValueError as e:
# Return 400 to prevent disclosure while keeping the stacktrace
L.error("Failed to produce session track ID")
raise aiohttp.web.HTTPBadRequest() from e

session = await self.CookieService.extend_session_expiration(session, client)
for param in {"client_id", "grant_type", "code"}:
if param not in parameters:
AuditLogger.log(
asab.LOG_NOTICE,
"Cookie request denied: No '{}' in request query".format(param),
struct_data={"access_ips": AccessIps.get()}
)
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400)

# Construct the response
if client.get("cookie_domain") not in (None, ""):
cookie_domain = client["cookie_domain"]
else:
cookie_domain = self.CookieService.RootCookieDomain
cookie, redirect_uri, client_headers = await self.CookieService.process_cookie_request(
request,
client_id=parameters["client_id"],
grant_type=parameters["grant_type"],
code=parameters["code"],
)

response = aiohttp.web.HTTPFound(
redirect_uri,
Expand All @@ -492,39 +390,17 @@ async def _bouncer(self, request, parameters):
text="<!doctype html>\n<html lang=\"en\">\n<head></head><body>...</body>\n</html>\n"
)

# TODO: Verify that the request came from the correct domain
# Add headers from webhook
response.headers.update(client_headers)

# Add Seacat Auth cookie
self.CookieService.set_session_cookie(
response=response,
cookie_value=session.Cookie.Id,
client_id=client_id,
cookie_domain=cookie_domain
client_id=parameters["client_id"],
cookie_value=cookie["value"],
cookie_domain=cookie.get("domain"),
)

# Trigger webhook and set custom client response headers
try:
data = await self._fetch_webhook_data(client, session)
if data is not None:
response.headers.update(data.get("response_headers", {}))
except exceptions.ClientResponseError as e:
L.log(asab.LOG_NOTICE, "Webhook responded with error", struct_data={
"status": e.Status, "text": e.Data})
AuditLogger.log(asab.LOG_NOTICE, "Cookie request denied: Webhook error", struct_data={
"cid": session.Credentials.Id,
"sid": session.Id,
"client_id": session.OAuth2.ClientId,
"from_ip": from_ip,
"redirect_uri": redirect_uri})
return asab.web.rest.json_response(
request, {"error": TokenRequestErrorResponseCode.InvalidRequest}, status=400)

AuditLogger.log(asab.LOG_NOTICE, "Cookie request granted", struct_data={
"cid": session.Credentials.Id,
"sid": session.Id,
"client_id": session.OAuth2.ClientId,
"from_ip": from_ip,
"redirect_uri": redirect_uri})

return response


Expand All @@ -542,34 +418,3 @@ async def _authenticate_request(self, request, client_id=None):
return None

return session


async def _fetch_webhook_data(self, client, session):
"""
Make a webhook request and return the response body.
The response should match the following schema:
```json
{
"type": "object",
"properties": {
"response_headers": {
"type": "object",
"description": "HTTP headers and their values that will be added to the response."
}
}
}
```
"""
cookie_webhook_uri = client.get("cookie_webhook_uri")
if cookie_webhook_uri is None:
return None
async with aiohttp.ClientSession() as http_session:
# TODO: Better serialization
userinfo = await self.CookieService.OpenIdConnectService.build_userinfo(session)
data = asab.web.rest.json.JSONDumper(pretty=False)(userinfo)
async with http_session.put(cookie_webhook_uri, data=data, headers={
"Content-Type": "application/json"}) as resp:
if resp.status != 200:
text = await resp.text()
raise exceptions.ClientResponseError(resp.status, text)
return await resp.json()
Loading