Skip to content
Open
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
31 changes: 31 additions & 0 deletions alembic/versions/596bb368fc0d_add_orcid_id_to_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Add orcid_id column to users

Revision ID: 596bb368fc0d
Revises: d3a7b8c1e2f4
Create Date: 2026-03-28

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

revision: str = "596bb368fc0d"
down_revision: Union[str, Sequence[str], None] = "d3a7b8c1e2f4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"users",
sa.Column("orcid_id", sa.String(20), nullable=True),
)
op.create_index("ix_users_orcid_id", "users", ["orcid_id"], unique=True)


def downgrade() -> None:
op.drop_index("ix_users_orcid_id", table_name="users")
op.drop_column("users", "orcid_id")
22 changes: 21 additions & 1 deletion app/models/user.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
from datetime import datetime, timezone
import re
import uuid

from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.orm import relationship, validates

from app.database import Base

ORCID_PATTERN = re.compile(r"^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$")


def validate_orcid_id(value: str | None) -> str | None:
"""Validate ORCID iD format: XXXX-XXXX-XXXX-XXXX (last char may be X checksum)."""
if value is None:
return None
if not ORCID_PATTERN.match(value):
raise ValueError(
f"Invalid ORCID iD format: '{value}'. "
"Expected format: 0000-0002-1234-5678"
)
return value


# Cross-platform UUID type that works with SQLite
class GUID(TypeDecorator):
Expand Down Expand Up @@ -58,11 +73,16 @@ class User(Base):
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
orcid_id = Column(String(20), nullable=True, unique=True, index=True)

# Relationships
scrolls = relationship("Scroll", back_populates="user")
sessions = relationship("Session", back_populates="user")
tokens = relationship("Token", back_populates="user")

@validates("orcid_id")
def _validate_orcid_id(self, _key: str, value: str | None) -> str | None:
return validate_orcid_id(value)

def __repr__(self):
return f"<User(email='{self.email}', display_name='{self.display_name}')>"
8 changes: 6 additions & 2 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ async def login_page(request: Request, db: AsyncSession = Depends(get_db)):
get_logger().info(f"Authenticated user {current_user.id} redirected from login page")
return RedirectResponse(url="/", status_code=302)

return templates.TemplateResponse(request, "auth/login.html", {"current_user": current_user})
error = request.query_params.get("error")
return templates.TemplateResponse(
request, "auth/login.html", {"current_user": current_user, "error": error}
)


@router.post("/logout")
Expand Down Expand Up @@ -244,8 +247,9 @@ async def register_page(request: Request, db: AsyncSession = Depends(get_db)):
get_logger().info(f"Authenticated user {current_user.id} redirected from register page")
return RedirectResponse(url="/", status_code=302)

error = request.query_params.get("error")
return templates.TemplateResponse(
request, "auth/register.html", {"current_user": current_user}
request, "auth/register.html", {"current_user": current_user, "error": error}
)


Expand Down
5 changes: 5 additions & 0 deletions app/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,9 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db)):
is_boosted = request.headers.get("HX-Boosted") == "true"
use_partial = is_htmx and not is_boosted

error = request.query_params.get("error")
orcid_status = request.query_params.get("orcid")

return templates.TemplateResponse(
request,
"dashboard_content.html" if use_partial else "dashboard.html",
Expand All @@ -605,6 +608,8 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db)):
"papers": papers,
"drafts": drafts,
"csrf_token": csrf_token,
"error": error,
"orcid_status": orcid_status,
},
)

Expand Down
209 changes: 209 additions & 0 deletions app/routes/orcid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""ORCID OAuth2 authentication routes."""

import os
import secrets

from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.session import create_session, get_current_user_from_session
from app.database import get_db
from app.logging_config import get_logger
from app.models.user import User

router = APIRouter(prefix="/auth/orcid")

IS_PRODUCTION = os.getenv("ENVIRONMENT") == "production"

ORCID_CLIENT_ID = os.getenv("ORCID_CLIENT_ID", "")
ORCID_CLIENT_SECRET = os.getenv("ORCID_CLIENT_SECRET", "")
ORCID_BASE_URL = os.getenv("ORCID_BASE_URL", "https://sandbox.orcid.org")

# Pending OAuth states: state_token -> True
# Short-lived, cleared on use. In production, use Redis/DB before horizontal scaling.
_pending_states: dict[str, bool] = {}


def _get_redirect_uri(request: Request) -> str:
"""Build the ORCID callback URI from the current request."""
return str(request.url_for("orcid_callback"))


@router.get("", name="orcid_redirect")
async def orcid_redirect(request: Request, db: AsyncSession = Depends(get_db)):
"""Redirect to ORCID authorize URL with CSRF state."""
if not ORCID_CLIENT_ID or not ORCID_CLIENT_SECRET:
current_user = await get_current_user_from_session(request, db)
error_url = "/dashboard?error=orcid_not_configured" if current_user else "/login?error=orcid_not_configured"
return RedirectResponse(url=error_url, status_code=302)

state = secrets.token_urlsafe(32)
_pending_states[state] = True

redirect_uri = _get_redirect_uri(request)
authorize_url = (
f"{ORCID_BASE_URL}/oauth/authorize"
f"?client_id={ORCID_CLIENT_ID}"
f"&response_type=code"
f"&scope=/authenticate"
f"&redirect_uri={redirect_uri}"
f"&state={state}"
)

response = RedirectResponse(url=authorize_url, status_code=302)
response.set_cookie(
"orcid_state", state, httponly=True, secure=IS_PRODUCTION,
samesite="lax", max_age=600,
)
return response


@router.get("/callback", name="orcid_callback")
async def orcid_callback(
request: Request,
code: str | None = None,
state: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""Handle ORCID OAuth2 callback."""
logger = get_logger()
current_user = await get_current_user_from_session(request, db)

def _error_redirect(error: str) -> RedirectResponse:
base = "/dashboard" if current_user else "/login"
return RedirectResponse(url=f"{base}?error={error}", status_code=302)

# Validate state
cookie_state = request.cookies.get("orcid_state")
if not state or not cookie_state or state != cookie_state or state not in _pending_states:
logger.warning("ORCID callback: invalid or missing state")
return _error_redirect("orcid_state")

_pending_states.pop(state, None)

if not code:
logger.warning("ORCID callback: missing code")
return _error_redirect("orcid_missing_code")

# Exchange code for token
redirect_uri = _get_redirect_uri(request)
try:
async with httpx.AsyncClient() as client:
token_resp = await client.post(
f"{ORCID_BASE_URL}/oauth/token",
data={
"client_id": ORCID_CLIENT_ID,
"client_secret": ORCID_CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
headers={"Accept": "application/json"},
)

if token_resp.status_code != 200:
logger.error(f"ORCID token exchange failed: {token_resp.status_code}")
return _error_redirect("orcid_token")

token_data = token_resp.json()
except Exception as e:
logger.error(f"ORCID token exchange error: {e}")
return _error_redirect("orcid_token")

orcid_id = token_data.get("orcid")
orcid_name = token_data.get("name", "")

if not orcid_id:
logger.error("ORCID token response missing orcid field")
return _error_redirect("orcid_token")

if current_user:
return await _link_orcid(db, current_user, orcid_id)

return await _login_or_register(db, orcid_id, orcid_name)


async def _link_orcid(db: AsyncSession, user: User, orcid_id: str) -> RedirectResponse:
"""Link ORCID to an existing logged-in user."""
logger = get_logger()

# Check if ORCID is already taken by another user
result = await db.execute(select(User).where(User.orcid_id == orcid_id))
existing = result.scalar_one_or_none()
if existing and existing.id != user.id:
logger.warning(f"ORCID {orcid_id} already linked to user {existing.id}")
return RedirectResponse(url="/dashboard?error=orcid_taken", status_code=302)

user.orcid_id = orcid_id
await db.commit()
logger.info(f"Linked ORCID {orcid_id} to user {user.id}")
return RedirectResponse(url="/dashboard?orcid=linked", status_code=302)


async def _login_or_register(
db: AsyncSession, orcid_id: str, orcid_name: str,
) -> RedirectResponse:
"""Log in existing ORCID user or create a new account."""
logger = get_logger()

result = await db.execute(select(User).where(User.orcid_id == orcid_id))
user = result.scalar_one_or_none()

if user:
session_id = await create_session(db, user.id)
logger.info(f"ORCID login for user {user.id}")
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
"session_id", session_id, httponly=True,
secure=IS_PRODUCTION, samesite="lax",
)
return response

# Create new user
# Generate a placeholder email using ORCID (users can update it later)
placeholder_email = f"{orcid_id}@orcid.placeholder"
display_name = orcid_name.strip() if orcid_name else f"ORCID User {orcid_id[-4:]}"

new_user = User(
email=placeholder_email,
password_hash="!orcid-only",
display_name=display_name,
email_verified=True,
orcid_id=orcid_id,
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)

session_id = await create_session(db, new_user.id)
logger.info(f"Created new user {new_user.id} via ORCID {orcid_id}")

response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
"session_id", session_id, httponly=True,
secure=IS_PRODUCTION, samesite="lax",
)
return response


@router.get("/unlink", name="orcid_unlink")
async def orcid_unlink(request: Request, db: AsyncSession = Depends(get_db)):
"""Remove ORCID from the current user's account."""
current_user = await get_current_user_from_session(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=302)

logger = get_logger()

# Block unlink if user has no password (would lock them out)
if not current_user.password_hash or current_user.password_hash == "!orcid-only":
logger.warning(f"User {current_user.id} tried to unlink ORCID without password")
return RedirectResponse(url="/dashboard?error=orcid_no_password", status_code=302)

current_user.orcid_id = None
await db.commit()
logger.info(f"Unlinked ORCID from user {current_user.id}")
return RedirectResponse(url="/dashboard?orcid=unlinked", status_code=302)
25 changes: 25 additions & 0 deletions app/templates/auth/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,33 @@ <h1>Sign In</h1>
<p>Sign in to your Press account</p>
</div>

{% if error %}
<div class="alert alert-error" role="alert">
{% if error == "orcid_not_configured" %}
ORCID sign-in is not available at this time.
{% elif error == "orcid_state" %}
ORCID sign-in failed. Please try again.
{% elif error == "orcid_token" %}
Could not complete ORCID sign-in. Please try again.
{% elif error == "orcid_missing_code" %}
ORCID sign-in was cancelled or failed. Please try again.
{% else %}
An error occurred. Please try again.
{% endif %}
</div>
{% endif %}

{% include "auth/partials/login_form.html" %}

<div class="auth-divider"><span>or</span></div>
<a href="/auth/orcid" class="btn btn-orcid">
<svg class="orcid-icon" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<path fill="#a6ce39" d="M256 128c0 70.7-57.3 128-128 128S0 198.7 0 128 57.3 0 128 0s128 57.3 128 128z"/>
<path fill="#fff" d="M86.3 186.2H70.9V79.1h15.4v107.1zm22.3 0h41.6c39.6 0 57-28.3 57-53.6 0-27.2-21.1-53.6-56.8-53.6h-41.8v107.2zm15.4-93.3h24.5c34.9 0 42.9 26.5 42.9 39.7 0 21.5-13.7 39.7-43.7 39.7h-23.7V92.9zm-58.1-23c5.7 0 10.3-4.6 10.3-10.3S71.6 49.3 65.9 49.3c-5.7 0-10.3 4.6-10.3 10.3s4.6 10.3 10.3 10.3z"/>
</svg>
Sign in with ORCID
</a>

<div class="auth-links">
<p><a href="/forgot-password">Forgot your password?</a></p>
<p>Don't have an account? <a href="/register">Create one here</a></p>
Expand Down
23 changes: 23 additions & 0 deletions app/templates/auth/register.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,31 @@ <h1>Create Your Account</h1>
<p>Join the academic community on Press</p>
</div>

{% if error %}
<div class="alert alert-error" role="alert">
{% if error == "orcid_not_configured" %}
ORCID sign-up is not available at this time.
{% elif error == "orcid_state" %}
ORCID sign-up failed. Please try again.
{% elif error == "orcid_token" %}
Could not complete ORCID sign-up. Please try again.
{% else %}
An error occurred. Please try again.
{% endif %}
</div>
{% endif %}

{% include "auth/partials/register_form.html" %}

<div class="auth-divider"><span>or</span></div>
<a href="/auth/orcid" class="btn btn-orcid">
<svg class="orcid-icon" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<path fill="#a6ce39" d="M256 128c0 70.7-57.3 128-128 128S0 198.7 0 128 57.3 0 128 0s128 57.3 128 128z"/>
<path fill="#fff" d="M86.3 186.2H70.9V79.1h15.4v107.1zm22.3 0h41.6c39.6 0 57-28.3 57-53.6 0-27.2-21.1-53.6-56.8-53.6h-41.8v107.2zm15.4-93.3h24.5c34.9 0 42.9 26.5 42.9 39.7 0 21.5-13.7 39.7-43.7 39.7h-23.7V92.9zm-58.1-23c5.7 0 10.3-4.6 10.3-10.3S71.6 49.3 65.9 49.3c-5.7 0-10.3 4.6-10.3 10.3s4.6 10.3 10.3 10.3z"/>
</svg>
Sign up with ORCID
</a>

<div class="auth-links">
<p>Already have an account? <a href="/login">Sign in here</a></p>
</div>
Expand Down
Loading
Loading