Merge branch 'feature/consent-screen'
This commit is contained in:
commit
be35c17fa5
14 changed files with 654 additions and 12 deletions
|
|
@ -19,6 +19,7 @@ from porchlight.oidc.endpoints import router as oidc_router
|
|||
from porchlight.oidc.provider import create_oidc_server
|
||||
from porchlight.store.sqlite.db import open_db
|
||||
from porchlight.store.sqlite.repositories import (
|
||||
SQLiteConsentRepository,
|
||||
SQLiteCredentialRepository,
|
||||
SQLiteMagicLinkRepository,
|
||||
SQLiteUserRepository,
|
||||
|
|
@ -36,6 +37,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
app.state.user_repo = SQLiteUserRepository(db)
|
||||
app.state.credential_repo = SQLiteCredentialRepository(db)
|
||||
app.state.magic_link_repo = SQLiteMagicLinkRepository(db)
|
||||
app.state.consent_repo = SQLiteConsentRepository(db)
|
||||
|
||||
# Auth services
|
||||
app.state.password_service = PasswordService()
|
||||
|
|
|
|||
|
|
@ -56,3 +56,11 @@ class MagicLink(BaseModel):
|
|||
used: bool = False
|
||||
created_by: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class Consent(BaseModel):
|
||||
userid: str
|
||||
client_id: str
|
||||
scopes: list[str]
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
updated_at: datetime = Field(default_factory=_utcnow)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ from porchlight.oidc.claims import PorchlightUserInfo, user_to_claims
|
|||
|
||||
router = APIRouter(tags=["oidc"])
|
||||
|
||||
SCOPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"openid": "Sign you in (required)",
|
||||
"profile": "Your name and profile information",
|
||||
"email": "Your email address",
|
||||
"phone": "Your phone number",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/.well-known/openid-configuration")
|
||||
async def provider_configuration(request: Request) -> JSONResponse:
|
||||
|
|
@ -63,7 +70,7 @@ async def authorization(request: Request) -> Response:
|
|||
username = request.session.get("username")
|
||||
|
||||
if userid and username:
|
||||
return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username)
|
||||
return await _check_consent_or_complete(request, oidc_server, endpoint, parsed, userid, username, query_params)
|
||||
|
||||
# Not authenticated — store and redirect to login
|
||||
request.session["oidc_auth_request"] = query_params
|
||||
|
|
@ -94,7 +101,40 @@ async def authorization_complete(request: Request) -> Response:
|
|||
error_desc = parsed.get("error_description", parsed["error"])
|
||||
return HTMLResponse(f"<h1>Error</h1><p>{error_desc}</p>", status_code=400)
|
||||
|
||||
return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username)
|
||||
return await _check_consent_or_complete(
|
||||
request, oidc_server, endpoint, parsed, userid, username, auth_request_params
|
||||
)
|
||||
|
||||
|
||||
async def _check_consent_or_complete(
|
||||
request: Request,
|
||||
oidc_server: object,
|
||||
endpoint: object,
|
||||
parsed: object,
|
||||
userid: str,
|
||||
username: str,
|
||||
auth_params: dict,
|
||||
) -> Response:
|
||||
"""Check if consent is needed; if so redirect to /consent, otherwise complete."""
|
||||
settings = request.app.state.settings
|
||||
client_id = auth_params.get("client_id", "")
|
||||
|
||||
# Manage-app bypasses consent
|
||||
if client_id == settings.manage_client_id:
|
||||
return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username)
|
||||
|
||||
# Check stored consent
|
||||
consent_repo = request.app.state.consent_repo
|
||||
requested_scopes = auth_params.get("scope", "openid").split()
|
||||
stored_consent = await consent_repo.get_consent(userid, client_id)
|
||||
|
||||
if stored_consent and set(requested_scopes) <= set(stored_consent.scopes):
|
||||
# All requested scopes already approved
|
||||
return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username)
|
||||
|
||||
# Consent needed — store auth state and redirect
|
||||
request.session["consent_auth_request"] = auth_params
|
||||
return RedirectResponse("/consent", status_code=303)
|
||||
|
||||
|
||||
async def _complete_authorization(
|
||||
|
|
@ -246,3 +286,82 @@ async def userinfo_endpoint(request: Request) -> JSONResponse:
|
|||
response_data = response_data.to_dict()
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
|
||||
@router.get("/consent")
|
||||
async def consent_page(request: Request) -> Response:
|
||||
"""Show the consent form."""
|
||||
auth_params = request.session.get("consent_auth_request")
|
||||
if auth_params is None:
|
||||
return HTMLResponse("<h1>Error</h1><p>No pending consent request</p>", status_code=400)
|
||||
|
||||
userid = request.session.get("userid")
|
||||
if not userid:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
client_id = auth_params.get("client_id", "")
|
||||
requested_scopes = auth_params.get("scope", "openid").split()
|
||||
|
||||
scope_info = [
|
||||
{"name": s, "description": SCOPE_DESCRIPTIONS.get(s, s), "required": s == "openid"} for s in requested_scopes
|
||||
]
|
||||
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"consent.html",
|
||||
{"client_id": client_id, "scopes": scope_info},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/consent")
|
||||
async def consent_submit(request: Request) -> Response:
|
||||
"""Handle consent form submission."""
|
||||
auth_params = request.session.pop("consent_auth_request", None)
|
||||
if auth_params is None:
|
||||
return HTMLResponse("<h1>Error</h1><p>No pending consent request</p>", status_code=400)
|
||||
|
||||
userid = request.session.get("userid")
|
||||
username = request.session.get("username")
|
||||
if not userid or not username:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
form = await request.form()
|
||||
action = form.get("action")
|
||||
client_id = auth_params.get("client_id", "")
|
||||
redirect_uri = auth_params.get("redirect_uri", "")
|
||||
state = auth_params.get("state", "")
|
||||
|
||||
if action == "deny":
|
||||
params = urlencode({"error": "access_denied", "state": state})
|
||||
return RedirectResponse(f"{redirect_uri}?{params}", status_code=303)
|
||||
|
||||
if action != "allow":
|
||||
return HTMLResponse("<h1>Error</h1><p>Invalid action</p>", status_code=400)
|
||||
|
||||
# Allow — collect approved scopes
|
||||
approved_scopes: list[str] = [str(s) for s in form.getlist("scope")]
|
||||
if "openid" not in approved_scopes:
|
||||
approved_scopes = ["openid", *list(approved_scopes)]
|
||||
|
||||
# Save consent
|
||||
consent_repo = request.app.state.consent_repo
|
||||
await consent_repo.set_consent(userid, client_id, list(approved_scopes))
|
||||
|
||||
# Filter auth request scopes to only approved
|
||||
auth_params["scope"] = " ".join(approved_scopes)
|
||||
|
||||
# Re-parse and complete
|
||||
oidc_server = request.app.state.oidc_server
|
||||
endpoint = oidc_server.get_endpoint("authorization")
|
||||
|
||||
try:
|
||||
parsed = endpoint.parse_request(auth_params)
|
||||
except Exception as exc:
|
||||
return HTMLResponse(f"<h1>Error</h1><p>{exc}</p>", status_code=400)
|
||||
|
||||
if "error" in parsed:
|
||||
error_desc = parsed.get("error_description", parsed["error"])
|
||||
return HTMLResponse(f"<h1>Error</h1><p>{error_desc}</p>", status_code=400)
|
||||
|
||||
return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from porchlight.models import (
|
||||
Consent,
|
||||
MagicLink,
|
||||
PasswordCredential,
|
||||
User,
|
||||
|
|
@ -51,3 +52,14 @@ class MagicLinkRepository(Protocol):
|
|||
async def mark_used(self, token: str) -> bool: ...
|
||||
|
||||
async def delete_expired(self) -> int: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConsentRepository(Protocol):
|
||||
async def get_consent(self, userid: str, client_id: str) -> Consent | None: ...
|
||||
|
||||
async def set_consent(self, userid: str, client_id: str, scopes: list[str]) -> None: ...
|
||||
|
||||
async def delete_consent(self, userid: str, client_id: str) -> bool: ...
|
||||
|
||||
async def list_consents(self, userid: str) -> list[Consent]: ...
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE user_consents (
|
||||
userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (userid, client_id)
|
||||
);
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from porchlight.models import MagicLink, PasswordCredential, User, WebAuthnCredential
|
||||
from porchlight.models import Consent, MagicLink, PasswordCredential, User, WebAuthnCredential
|
||||
from porchlight.store.exceptions import DuplicateError
|
||||
|
||||
|
||||
|
|
@ -289,3 +290,56 @@ class SQLiteMagicLinkRepository:
|
|||
cursor = await self._db.execute("DELETE FROM magic_links WHERE expires_at < ? AND used = 0", (now,))
|
||||
await self._db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
class SQLiteConsentRepository:
|
||||
def __init__(self, db: aiosqlite.Connection) -> None:
|
||||
self._db = db
|
||||
|
||||
def _row_to_consent(self, row: aiosqlite.Row) -> Consent:
|
||||
return Consent(
|
||||
userid=row["userid"],
|
||||
client_id=row["client_id"],
|
||||
scopes=json.loads(row["scopes"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
)
|
||||
|
||||
async def get_consent(self, userid: str, client_id: str) -> Consent | None:
|
||||
async with self._db.execute(
|
||||
"SELECT * FROM user_consents WHERE userid = ? AND client_id = ?",
|
||||
(userid, client_id),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_consent(row)
|
||||
|
||||
async def set_consent(self, userid: str, client_id: str, scopes: list[str]) -> None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await self._db.execute(
|
||||
"""
|
||||
INSERT INTO user_consents (userid, client_id, scopes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (userid, client_id)
|
||||
DO UPDATE SET scopes = excluded.scopes, updated_at = excluded.updated_at
|
||||
""",
|
||||
(userid, client_id, json.dumps(scopes), now, now),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def delete_consent(self, userid: str, client_id: str) -> bool:
|
||||
cursor = await self._db.execute(
|
||||
"DELETE FROM user_consents WHERE userid = ? AND client_id = ?",
|
||||
(userid, client_id),
|
||||
)
|
||||
await self._db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def list_consents(self, userid: str) -> list[Consent]:
|
||||
async with self._db.execute(
|
||||
"SELECT * FROM user_consents WHERE userid = ? ORDER BY client_id",
|
||||
(userid,),
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_consent(row) for row in rows]
|
||||
|
|
|
|||
35
src/porchlight/templates/consent.html
Normal file
35
src/porchlight/templates/consent.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Authorize — Porchlight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card consent-card">
|
||||
<h1>Authorize {{ client_id }}</h1>
|
||||
<p>This application is requesting access to your account.</p>
|
||||
|
||||
<form method="post" action="/consent">
|
||||
<fieldset>
|
||||
<legend>Permissions requested</legend>
|
||||
<ul class="scope-list" role="list">
|
||||
{% for scope in scopes %}
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="scope" value="{{ scope.name }}"
|
||||
{% if scope.required %}checked disabled{% else %}checked{% endif %}>
|
||||
{{ scope.description }}
|
||||
</label>
|
||||
{% if scope.required %}
|
||||
<input type="hidden" name="scope" value="{{ scope.name }}">
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<div class="consent-actions">
|
||||
<button type="submit" name="action" value="allow" class="btn btn-primary">Allow</button>
|
||||
<button type="submit" name="action" value="deny" class="btn btn-secondary">Deny</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue