Session keys hardened (namespaced hashes of tokens). Various cleanup.
This commit is contained in:
+82
-20
@@ -15,19 +15,47 @@ import logging
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import db, oidauth
|
||||
from paskia import authcode, db
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.structs import Session
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def _oidc_session_by_token(
|
||||
token: str, client_uuid: UUID | None = None
|
||||
) -> Session | None:
|
||||
"""Look up an OIDC session by token (refresh token value)."""
|
||||
key = hash_secret("oidc", token)
|
||||
s = db.data().sessions.get(key)
|
||||
if not s or s.client_uuid is None:
|
||||
return None
|
||||
if client_uuid is not None and s.client_uuid != client_uuid:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session | None:
|
||||
"""Look up an OIDC session by sid (for backchannel logout)."""
|
||||
for s in db.data().sessions.values():
|
||||
if s.client_uuid is None:
|
||||
continue
|
||||
if client_uuid is not None and s.client_uuid != client_uuid:
|
||||
continue
|
||||
if hash_secret("oidc", s.key) == sid:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _get_issuer(request: Request) -> str:
|
||||
"""Build issuer URL from request."""
|
||||
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||
@@ -137,26 +165,33 @@ async def _handle_authorization_code(
|
||||
)
|
||||
|
||||
# Consume auth code (atomic delete + return)
|
||||
auth_code = oidauth.instance.consume(code)
|
||||
auth_code = authcode.codes.pop(code, None)
|
||||
if not auth_code:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Verify client matches
|
||||
if auth_code.client_uuid != client.uuid:
|
||||
return JSONResponse({"error": "invalid_grant"}, status_code=400)
|
||||
# Look up the OIDC session by token
|
||||
session = _oidc_session_by_token(auth_code.session_key, client.uuid)
|
||||
if not session:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Session not found or not OIDC",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Verify redirect_uri matches
|
||||
if redirect_uri and redirect_uri != auth_code.redirect_uri:
|
||||
# Verify redirect_uri matches (OIDC only)
|
||||
if auth_code.oidc and redirect_uri and redirect_uri != auth_code.oidc.redirect_uri:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Verify PKCE if code_challenge was provided
|
||||
if auth_code.code_challenge:
|
||||
# Verify PKCE (OIDC only)
|
||||
if auth_code.oidc:
|
||||
if not code_verifier:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -165,7 +200,7 @@ async def _handle_authorization_code(
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
method = auth_code.code_challenge_method or "S256"
|
||||
method = auth_code.oidc.code_challenge_method
|
||||
if method != "S256":
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -174,7 +209,7 @@ async def _handle_authorization_code(
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
if not _verify_pkce(code_verifier, auth_code.code_challenge):
|
||||
if not _verify_pkce(code_verifier, auth_code.oidc.code_challenge):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
@@ -183,16 +218,25 @@ async def _handle_authorization_code(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get user
|
||||
user = db.data().users.get(auth_code.user_uuid)
|
||||
# Get user from session
|
||||
user = db.data().users.get(session.user_uuid)
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_grant", "error_description": "User not found"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Derive sid from session key
|
||||
sid = base64url.enc(hash_secret("oidc", session.key))
|
||||
|
||||
return _build_token_response(
|
||||
request, user, client_id, auth_code.sid, auth_code.nonce, auth_code.scope
|
||||
request,
|
||||
user,
|
||||
client_id,
|
||||
auth_code.session_key,
|
||||
sid,
|
||||
auth_code.oidc.nonce if auth_code.oidc else None,
|
||||
auth_code.oidc.scope if auth_code.oidc else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -204,7 +248,7 @@ async def _handle_refresh_token(
|
||||
):
|
||||
"""Handle grant_type=refresh_token.
|
||||
|
||||
The refresh_token is the OIDC session sid. On refresh:
|
||||
The refresh_token is the session secret. On refresh:
|
||||
- Validates session exists and belongs to client
|
||||
- Extends session expiry (24h sliding window)
|
||||
- Records current IP and user_agent
|
||||
@@ -216,8 +260,8 @@ async def _handle_refresh_token(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Look up session by sid
|
||||
session = db.data().oidc_session_by_sid(refresh_token_value, client.uuid)
|
||||
# Look up session by refresh token
|
||||
session = _oidc_session_by_token(refresh_token_value, client.uuid)
|
||||
if not session:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -258,8 +302,17 @@ async def _handle_refresh_token(
|
||||
|
||||
_logger.info("OIDC session refreshed: %s", session.key)
|
||||
|
||||
# Base64url encode session's derived sid for JWT claim
|
||||
sid_str = base64url.enc(hash_secret("oidc", session.key))
|
||||
|
||||
return _build_token_response(
|
||||
request, user, client_id, session.key, nonce=None, scope="openid"
|
||||
request,
|
||||
user,
|
||||
client_id,
|
||||
refresh_token_value,
|
||||
sid_str,
|
||||
nonce=None,
|
||||
scope="openid",
|
||||
)
|
||||
|
||||
|
||||
@@ -267,6 +320,7 @@ def _build_token_response(
|
||||
request: Request,
|
||||
user,
|
||||
client_id: str,
|
||||
secret: str,
|
||||
sid: str,
|
||||
nonce: str | None,
|
||||
scope: str,
|
||||
@@ -312,7 +366,7 @@ def _build_token_response(
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": sid,
|
||||
"refresh_token": secret,
|
||||
"id_token": id_token,
|
||||
}
|
||||
)
|
||||
@@ -436,8 +490,16 @@ async def backchannel_logout(
|
||||
# Delete session(s)
|
||||
deleted = 0
|
||||
if sid:
|
||||
# Decode sid from base64url to bytes
|
||||
try:
|
||||
sid_bytes = base64url.dec(sid)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_request", "error_description": "Invalid sid format"},
|
||||
status_code=400,
|
||||
)
|
||||
# Delete specific session by sid
|
||||
session = db.data().oidc_session_by_sid(sid, client_uuid)
|
||||
session = _oidc_session_by_sid(sid_bytes, client_uuid)
|
||||
if session:
|
||||
db.delete_session(session.key)
|
||||
deleted = 1
|
||||
|
||||
Reference in New Issue
Block a user