diff --git a/pygitweb/auth.py b/pygitweb/auth.py
index 5980f1f..4579c01 100644
--- a/pygitweb/auth.py
+++ b/pygitweb/auth.py
@@ -1,8 +1,15 @@
 """
-Local and (future) OAuth authentication with credentials from ~/.pygitweb/auth.json.
-
-Browser login: GET/POST /login sets an HttpOnly cookie holding an opaque session id.
-APIs accept Authorization: Bearer <same session id>. Sessions live in memory only (see pygitweb.sessions).
+Authentication: local password login and OAuth2 authorization-code (browser).
+
+FastAPI security integration
+----------------------------
+- ``OAuth2PasswordBearer`` + ``APIKeyCookie``: extract PyGitWeb's opaque session id from
+  ``Authorization: Bearer`` or the login cookie. This is *not* an IdP JWT; OpenAPI still
+  documents the password ``/token`` flow that mints these session ids.
+- ``OAuth2PasswordRequestForm``: local ``POST /token`` only (resource-owner password grant).
+- External IdP login (Google/GitHub) uses standard authorization-code redirects implemented
+  in ``auth_oauth``; ``OAuth2AuthorizationCodeBearer`` is intentionally not used because
+  clients never receive or send the provider's access token—only our session id.
 """
 
 from __future__ import annotations
@@ -19,7 +26,9 @@ from pygitweb.auth_config import (
 	auth_config,
 	is_auth_configured,
 	is_local_auth_available,
+	is_oauth_auth_available,
 )
+from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, oauth_router
 from pygitweb.config import settings
 from pygitweb.sessions import create_session, get_session, revoke_session
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
@@ -31,6 +40,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
 _access_token_cookie = APIKeyCookie(name=ACCESS_TOKEN_COOKIE_NAME, auto_error=False)
 
 auth_router = APIRouter(tags=["auth"])
+auth_router.include_router(oauth_router)
 
 
 class User(BaseModel):
@@ -41,7 +51,7 @@ class UserInDB(User):
 	password: str
 
 
-def get_user(username: str) -> UserInDB | None:
+def get_local_user(username: str) -> UserInDB | None:
 	if not settings.AUTH or not is_local_auth_available(auth_config):
 		return None
 	for entry in auth_config.local_users:
@@ -52,8 +62,10 @@ def get_user(username: str) -> UserInDB | None:
 	return None
 
 
-def authenticate_user(username: str, password: str) -> User | None:
-	user = get_user(username)
+def authenticate_local_user(username: str, password: str) -> User | None:
+	if auth_config.auth_mode not in ("local", "both"):
+		return None
+	user = get_local_user(username)
 	if user is None:
 		return None
 	if not hmac.compare_digest(user.password, password):
@@ -61,22 +73,12 @@ def authenticate_user(username: str, password: str) -> User | None:
 	return User(username=user.username)
 
 
-def is_valid_session_username(username: str) -> bool:
-	if auth_config.auth_mode in ("local", "both"):
-		for entry in auth_config.local_users:
-			if hmac.compare_digest(entry.user, username):
-				return True
-	return False
-
-
 def decode_access_token(token: str) -> User | None:
 	if not settings.AUTH:
 		return None
 	rec = get_session(token)
 	if rec is None:
 		return None
-	if not is_valid_session_username(rec.username):
-		return None
 	return User(username=rec.username)
 
 
@@ -136,6 +138,9 @@ async def login_page(
 		site_name=settings.SITE_NAME,
 		next=next_safe,
 		error=None,
+		local_available=is_local_auth_available(auth_config),
+		oauth_available=is_oauth_auth_available(auth_config),
+		oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
 	)
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
@@ -148,17 +153,22 @@ async def login_form_submit(
 ) -> HTMLResponse | RedirectResponse:
 	if not settings.AUTH:
 		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	if not is_local_auth_available(auth_config):
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Local sign-in is not enabled")
 	next_safe = safe_next_url(next)
-	user = authenticate_user(username.strip(), password)
+	user = authenticate_local_user(username.strip(), password)
 	if user is None:
 		pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
 		body = env.get_template("login.html").render(
 			site_name=settings.SITE_NAME,
 			next=next_safe,
 			error="Incorrect username or password.",
+			local_available=True,
+			oauth_available=is_oauth_auth_available(auth_config),
+			oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
 		)
 		return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
-	sid = create_session(user.username)
+	sid = create_session(user.username, auth_method="local")
 	resp = RedirectResponse(url=next_safe, status_code=303)
 	resp.set_cookie(
 		key=ACCESS_TOKEN_COOKIE_NAME,
@@ -179,6 +189,7 @@ async def logout_page(
 	revoke_session(token)
 	resp = RedirectResponse(url=safe_next_url(next), status_code=303)
 	resp.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, path="/")
+	resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
 	return resp
 
 
@@ -186,10 +197,12 @@ async def logout_page(
 async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]:
 	if not settings.AUTH:
 		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	user = authenticate_user(form_data.username, form_data.password)
+	if not is_local_auth_available(auth_config):
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Local sign-in is not enabled")
+	user = authenticate_local_user(form_data.username, form_data.password)
 	if user is None:
 		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect username or password")
-	sid = create_session(user.username)
+	sid = create_session(user.username, auth_method="local")
 	return {"access_token": sid, "token_type": "bearer"}
 
 
diff --git a/pygitweb/auth.schema.json b/pygitweb/auth.schema.json
index 78ca2cb..f704742 100644
--- a/pygitweb/auth.schema.json
+++ b/pygitweb/auth.schema.json
@@ -7,6 +7,14 @@
         "type": "string",
         "pattern": "^(oauth|local|both)$"
       },
+      "oauth_provider": {
+        "type": "string",
+        "pattern": "^(google|github)?$"
+      },
+      "oauth_client_id": {
+        "type": "string",
+        "minLength": 0
+      },
       "oauth_client_secret": {
         "type": "string",
         "minLength": 0
diff --git a/pygitweb/auth_config.py b/pygitweb/auth_config.py
index 388ee9f..dcab494 100644
--- a/pygitweb/auth_config.py
+++ b/pygitweb/auth_config.py
@@ -51,6 +51,7 @@ def _require_auth_config_mode(path: Path) -> None:
 
 
 AuthMode = Literal["oauth", "local", "both"]
+OAuthProvider = Literal["google", "github"]
 OAuthUsernameFrom = Literal["sub", "email", "preffered_username", "login", "name"]
 
 
@@ -65,6 +66,8 @@ class AuthConfig(BaseModel):
 	model_config = ConfigDict(populate_by_name=True)
 
 	auth_mode: AuthMode = "local"
+	oauth_provider: OAuthProvider | str = ""
+	oauth_client_id: str = ""
 	oauth_client_secret: str = ""
 	oauth_redirect_uri: str = ""
 	oauth_username_from: OAuthUsernameFrom = "email"
@@ -91,6 +94,8 @@ def auth_config_path(settings: Settings) -> Path:
 def default_auth_config(*, admin_password: str) -> AuthConfig:
 	return AuthConfig(
 		auth_mode="local",
+		oauth_provider="",
+		oauth_client_id="",
 		oauth_client_secret="",
 		oauth_redirect_uri="",
 		oauth_username_from="email",
@@ -122,7 +127,13 @@ def is_local_auth_available(config: AuthConfig) -> bool:
 def is_oauth_auth_available(config: AuthConfig) -> bool:
 	if config.auth_mode not in ("oauth", "both"):
 		return False
-	return bool(config.oauth_client_secret.strip() and config.oauth_redirect_uri.strip())
+	provider = str(config.oauth_provider).strip()
+	return bool(
+		provider in ("google", "github")
+		and config.oauth_client_id.strip()
+		and config.oauth_client_secret.strip()
+		and config.oauth_redirect_uri.strip()
+	)
 
 
 def is_auth_configured(config: AuthConfig) -> bool:
diff --git a/pygitweb/auth_oauth.py b/pygitweb/auth_oauth.py
new file mode 100644
index 0000000..f1b62ed
--- /dev/null
+++ b/pygitweb/auth_oauth.py
@@ -0,0 +1,321 @@
+"""
+OAuth2 authorization-code flow (Google / GitHub) for browser login.
+
+After the IdP redirects back, PyGitWeb issues the same opaque in-process session id used
+for local login (cookie / Bearer), not an IdP access token. FastAPI's OAuth2* security
+classes only extract credentials from requests; they do not implement this redirect flow.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import secrets
+import time
+from dataclasses import dataclass
+from typing import Annotated, Any
+from urllib.parse import urlencode
+
+import httpx
+from fastapi import APIRouter, HTTPException, Query, Request, status
+from fastapi.responses import RedirectResponse
+
+from pygitweb.auth_config import AuthConfig, OAuthUsernameFrom, auth_config, is_oauth_auth_available
+from pygitweb.config import settings
+from pygitweb.sessions import create_session
+
+OAUTH_STATE_COOKIE_NAME = "pygitweb_oauth_state"
+OAUTH_STATE_MAX_AGE = 600
+ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
+LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+
+oauth_router = APIRouter(tags=["auth"])
+
+
+def _safe_next_url(next_raw: str | None) -> str:
+	n = (next_raw or "/").strip()
+	if not n.startswith("/") or n.startswith("//"):
+		return "/"
+	return n
+
+
+@dataclass(frozen=True)
+class _OAuthProvider:
+	authorize_url: str
+	token_url: str
+	scopes: str
+	userinfo_url: str | None
+
+
+_PROVIDERS: dict[str, _OAuthProvider] = {
+	"google": _OAuthProvider(
+		authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
+		token_url="https://oauth2.googleapis.com/token",
+		scopes="openid email profile",
+		userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
+	),
+	"github": _OAuthProvider(
+		authorize_url="https://github.com/login/oauth/authorize",
+		token_url="https://github.com/login/oauth/access_token",
+		scopes="read:user user:email",
+		userinfo_url=None,
+	),
+}
+
+
+def _provider(config: AuthConfig) -> _OAuthProvider:
+	name = str(config.oauth_provider).strip()
+	prov = _PROVIDERS.get(name)
+	if prov is None:
+		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
+	return prov
+
+
+def _seal_oauth_state(payload: dict[str, Any], secret: str) -> str:
+	body = {**payload, "exp": time.time() + OAUTH_STATE_MAX_AGE}
+	data = json.dumps(body, separators=(",", ":")).encode()
+	sig = hmac.new(secret.encode(), data, hashlib.sha256).digest()
+	return base64.urlsafe_b64encode(data + b"." + base64.urlsafe_b64encode(sig)).decode()
+
+
+def _unseal_oauth_state(token: str, secret: str) -> dict[str, Any]:
+	try:
+		raw = base64.urlsafe_b64decode(token.encode())
+		data, sig_b64 = raw.rsplit(b".", 1)
+		sig = base64.urlsafe_b64decode(sig_b64)
+		expected = hmac.new(secret.encode(), data, hashlib.sha256).digest()
+		if not hmac.compare_digest(sig, expected):
+			raise ValueError("invalid signature")
+		payload: dict[str, Any] = json.loads(data)
+		if float(payload.get("exp", 0)) < time.time():
+			raise ValueError("expired")
+		return payload
+	except (ValueError, json.JSONDecodeError, KeyError) as exc:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
+
+
+def _pkce_pair() -> tuple[str, str]:
+	verifier = secrets.token_urlsafe(48)
+	digest = hashlib.sha256(verifier.encode()).digest()
+	challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
+	return verifier, challenge
+
+
+def _claim(profile: dict[str, Any], claim: OAuthUsernameFrom) -> str | None:
+	if claim == "sub":
+		raw = profile.get("sub") or profile.get("id")
+	elif claim == "email":
+		raw = profile.get("email")
+	elif claim == "preffered_username":
+		raw = profile.get("preferred_username") or profile.get("preffered_username")
+	elif claim == "login":
+		raw = profile.get("login")
+	elif claim == "name":
+		raw = profile.get("name")
+	else:
+		raw = None
+	if raw is None:
+		return None
+	return str(raw)
+
+
+def oauth_username_from_profile(config: AuthConfig, profile: dict[str, Any]) -> str:
+	username = _claim(profile, config.oauth_username_from)
+	if username:
+		return username
+	for fallback in ("sub", "email", "login", "id"):
+		val = profile.get(fallback)
+		if val is not None:
+			return str(val)
+	return "unknown"
+
+
+def is_oauth_email_allowed(config: AuthConfig, email: str | None) -> bool:
+	if not config.oauth_allowed_emails:
+		return True
+	if not email:
+		return False
+	allowed = {e.strip().lower() for e in config.oauth_allowed_emails if e.strip()}
+	return email.strip().lower() in allowed
+
+
+async def _exchange_code(
+	config: AuthConfig,
+	*,
+	code: str,
+	code_verifier: str,
+) -> dict[str, Any]:
+	prov = _provider(config)
+	data = {
+		"grant_type": "authorization_code",
+		"code": code,
+		"redirect_uri": config.oauth_redirect_uri,
+		"client_id": config.oauth_client_id,
+		"client_secret": config.oauth_client_secret,
+		"code_verifier": code_verifier,
+	}
+	headers = {"Accept": "application/json"}
+	async with httpx.AsyncClient(timeout=30.0) as client:
+		resp = await client.post(prov.token_url, data=data, headers=headers)
+	resp.raise_for_status()
+	out = resp.json()
+	if not isinstance(out, dict):
+		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid token response from provider")
+	return out
+
+
+async def _fetch_google_profile(access_token: str) -> dict[str, Any]:
+	prov = _provider(auth_config)
+	assert prov.userinfo_url is not None
+	headers = {"Authorization": f"Bearer {access_token}"}
+	async with httpx.AsyncClient(timeout=30.0) as client:
+		resp = await client.get(prov.userinfo_url, headers=headers)
+	resp.raise_for_status()
+	profile = resp.json()
+	if not isinstance(profile, dict):
+		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid userinfo response")
+	return profile
+
+
+async def _fetch_github_profile(access_token: str) -> dict[str, Any]:
+	headers = {
+		"Authorization": f"Bearer {access_token}",
+		"Accept": "application/vnd.github+json",
+	}
+	async with httpx.AsyncClient(timeout=30.0) as client:
+		user_resp = await client.get("https://api.github.com/user", headers=headers)
+		user_resp.raise_for_status()
+		profile = user_resp.json()
+		if not isinstance(profile, dict):
+			raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid GitHub user response")
+		if profile.get("email"):
+			return profile
+		email_resp = await client.get("https://api.github.com/user/emails", headers=headers)
+		if email_resp.is_success:
+			emails = email_resp.json()
+			if isinstance(emails, list):
+				for entry in emails:
+					if isinstance(entry, dict) and entry.get("primary") and entry.get("verified"):
+						profile = {**profile, "email": entry.get("email")}
+						break
+	return profile
+
+
+async def _fetch_profile(config: AuthConfig, token_payload: dict[str, Any]) -> dict[str, Any]:
+	access_token = token_payload.get("access_token")
+	if not isinstance(access_token, str) or not access_token:
+		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Provider did not return an access token")
+	provider = str(config.oauth_provider).strip()
+	if provider == "google":
+		return await _fetch_google_profile(access_token)
+	if provider == "github":
+		return await _fetch_github_profile(access_token)
+	raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
+
+
+def _profile_subject(profile: dict[str, Any]) -> str | None:
+	for key in ("sub", "id"):
+		val = profile.get(key)
+		if val is not None:
+			return str(val)
+	return None
+
+
+def _profile_email(profile: dict[str, Any]) -> str | None:
+	email = profile.get("email")
+	return str(email) if email else None
+
+
+@oauth_router.get("/auth/oauth/start")
+async def oauth_start(
+	next: Annotated[str, Query()] = "/",
+) -> RedirectResponse:
+	if not settings.AUTH:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	if not is_oauth_auth_available(auth_config):
+		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
+	prov = _provider(auth_config)
+	verifier, challenge = _pkce_pair()
+	idp_state = secrets.token_urlsafe(32)
+	sealed = _seal_oauth_state(
+		{
+			"state": idp_state,
+			"next": _safe_next_url(next),
+			"code_verifier": verifier,
+		},
+		auth_config.oauth_client_secret,
+	)
+	params = {
+		"response_type": "code",
+		"client_id": auth_config.oauth_client_id,
+		"redirect_uri": auth_config.oauth_redirect_uri,
+		"scope": prov.scopes,
+		"state": idp_state,
+		"code_challenge": challenge,
+		"code_challenge_method": "S256",
+	}
+	url = f"{prov.authorize_url}?{urlencode(params)}"
+	resp = RedirectResponse(url=url, status_code=303)
+	resp.set_cookie(
+		key=OAUTH_STATE_COOKIE_NAME,
+		value=sealed,
+		httponly=True,
+		samesite="lax",
+		max_age=OAUTH_STATE_MAX_AGE,
+		path="/",
+	)
+	return resp
+
+
+@oauth_router.get("/auth/oauth/callback")
+async def oauth_callback(
+	request: Request,
+	code: Annotated[str | None, Query()] = None,
+	state: Annotated[str | None, Query()] = None,
+	error: Annotated[str | None, Query()] = None,
+) -> RedirectResponse:
+	if not settings.AUTH:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+	if not is_oauth_auth_available(auth_config):
+		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
+	if error:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"OAuth provider error: {error}")
+	if not code or not state:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth code or state")
+	sealed = request.cookies.get(OAUTH_STATE_COOKIE_NAME)
+	if not sealed:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth state cookie")
+	payload = _unseal_oauth_state(sealed, auth_config.oauth_client_secret)
+	if not hmac.compare_digest(str(payload.get("state", "")), state):
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch")
+	code_verifier = payload.get("code_verifier")
+	if not isinstance(code_verifier, str) or not code_verifier:
+		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state payload")
+	next_url = _safe_next_url(str(payload.get("next") or "/"))
+
+	token_payload = await _exchange_code(auth_config, code=code, code_verifier=code_verifier)
+	profile = await _fetch_profile(auth_config, token_payload)
+	email = _profile_email(profile)
+	if not is_oauth_email_allowed(auth_config, email):
+		raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
+	username = oauth_username_from_profile(auth_config, profile)
+	subject = _profile_subject(profile)
+	sid = create_session(
+		username,
+		subject=subject,
+		email=email,
+		auth_method="oauth",
+	)
+	resp = RedirectResponse(url=next_url, status_code=303)
+	resp.set_cookie(
+		key=ACCESS_TOKEN_COOKIE_NAME,
+		value=sid,
+		httponly=True,
+		samesite="lax",
+		max_age=LOGIN_COOKIE_MAX_AGE,
+		path="/",
+	)
+	resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
+	return resp
diff --git a/pygitweb/auth_test.py b/pygitweb/auth_test.py
index aafa9e8..35fc19b 100644
--- a/pygitweb/auth_test.py
+++ b/pygitweb/auth_test.py
@@ -2,14 +2,16 @@ import os
 import sys
 from collections.abc import Generator
 from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import AsyncMock, patch
 
 import pytest
 from fastapi.testclient import TestClient
 
 from pygitweb.auth_config import AuthConfig, LocalUser
+from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, _seal_oauth_state
 from pygitweb.config import settings
 from pygitweb.main import app
+from pygitweb.sessions import get_session
 
 _LOCAL_ADMIN = AuthConfig(
 	auth_mode="local",
@@ -182,3 +184,84 @@ def test_auth_config_rejects_insecure_permissions(tmp_path: Path) -> None:
 	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
 	with pytest.raises(AuthConfigPermissionError, match="0600"):
 		init_auth_config(s)
+
+
+def test_local_login_stores_session_audit_fields(client: TestClient, local_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
+		client.post(
+			"/login",
+			data={"username": "admin", "password": "secret", "next": "/"},
+			follow_redirects=False,
+		)
+		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
+	rec = get_session(tok)
+	assert rec is not None
+	assert rec.username == "admin"
+	assert rec.auth_method == "local"
+	assert rec.subject is None
+	assert rec.email is None
+
+
+_OAUTH_CFG = AuthConfig(
+	auth_mode="oauth",
+	oauth_provider="google",
+	oauth_client_id="test-client-id",
+	oauth_client_secret="test-client-secret",
+	oauth_redirect_uri="http://testserver/auth/oauth/callback",
+	oauth_allowed_emails=[],
+	local_users=[],
+)
+
+
+@pytest.fixture
+def oauth_auth_config() -> Generator[AuthConfig, None, None]:
+	with (
+		patch("pygitweb.auth_config.auth_config", _OAUTH_CFG),
+		patch("pygitweb.auth.auth_config", _OAUTH_CFG),
+		patch("pygitweb.auth_oauth.auth_config", _OAUTH_CFG),
+		patch("pygitweb.main.auth_config", _OAUTH_CFG),
+	):
+		yield _OAUTH_CFG
+
+
+def test_oauth_start_redirects_to_provider(client: TestClient, oauth_auth_config: AuthConfig) -> None:
+	with patch.object(settings, "AUTH", True):
+		r = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
+	assert r.status_code == 303
+	assert "accounts.google.com" in r.headers["location"]
+	assert OAUTH_STATE_COOKIE_NAME in r.cookies
+
+
+def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: AuthConfig) -> None:
+	sealed = _seal_oauth_state(
+		{"state": "idp-state", "next": "/", "code_verifier": "verifier"},
+		oauth_auth_config.oauth_client_secret,
+	)
+	token_payload = {"access_token": "provider-token"}
+	profile = {"sub": "oauth-sub-1", "email": "user@example.com", "preferred_username": "oauthuser"}
+	with (
+		patch.object(settings, "AUTH", True),
+		patch("pygitweb.auth_oauth._exchange_code", new_callable=AsyncMock, return_value=token_payload),
+		patch("pygitweb.auth_oauth._fetch_profile", new_callable=AsyncMock, return_value=profile),
+	):
+		r = client.get(
+			"/auth/oauth/callback",
+			params={"code": "auth-code", "state": "idp-state"},
+			cookies={OAUTH_STATE_COOKIE_NAME: sealed},
+			follow_redirects=False,
+		)
+	assert r.status_code == 303
+	rec = get_session(r.cookies["pygitweb_access_token"])
+	assert rec is not None
+	assert rec.username == "user@example.com"
+	assert rec.subject == "oauth-sub-1"
+	assert rec.email == "user@example.com"
+	assert rec.auth_method == "oauth"
+	assert client.get("/users/me").json()["username"] == "user@example.com"
+
+
+def test_authenticate_local_user_disabled_in_oauth_only_mode() -> None:
+	from pygitweb.auth import authenticate_local_user
+
+	with patch("pygitweb.auth.auth_config", _OAUTH_CFG):
+		assert authenticate_local_user("admin", "secret") is None
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index 7b6ec90..b413fb5 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -17,6 +17,7 @@ dependencies = [
     "orjson>=0.19.0",
     "pygit2>=1.12.0",
     "pydantic-settings>=2.13.0",
+    "httpx>=0.27.0",
     "python-ripgrep==0.0.9",
 ]
 
diff --git a/pygitweb/sessions.py b/pygitweb/sessions.py
index c06f17d..030ef0c 100644
--- a/pygitweb/sessions.py
+++ b/pygitweb/sessions.py
@@ -10,23 +10,41 @@ import secrets
 import threading
 import time
 from dataclasses import dataclass
+from typing import Literal
 
 _lock = threading.Lock()
 
+AuthMethod = Literal["local", "oauth"]
+
 
 @dataclass(frozen=True)
 class Session:
 	username: str
 	created_at: float
+	subject: str | None = None
+	email: str | None = None
+	auth_method: AuthMethod = "local"
 
 
 _sessions: dict[str, Session] = {}
 
 
-def create_session(username: str) -> str:
+def create_session(
+	username: str,
+	*,
+	subject: str | None = None,
+	email: str | None = None,
+	auth_method: AuthMethod = "local",
+) -> str:
 	sid = secrets.token_urlsafe(32)
 	with _lock:
-		_sessions[sid] = Session(username=username, created_at=time.time())
+		_sessions[sid] = Session(
+			username=username,
+			created_at=time.time(),
+			subject=subject,
+			email=email,
+			auth_method=auth_method,
+		)
 	return sid
 
 
diff --git a/pygitweb/templates/login.html b/pygitweb/templates/login.html
index d991877..47ba6c9 100644
--- a/pygitweb/templates/login.html
+++ b/pygitweb/templates/login.html
@@ -4,6 +4,17 @@
     {% if error %}
     <div class="alert alert-danger mb-3" role="alert">{{ error }}</div>
     {% endif %}
+    {% if oauth_available %}
+    <p class="mb-3">
+      <a class="btn btn-outline-primary" href="/auth/oauth/start?next={{ next | urlencode }}">
+        Sign in with {{ oauth_provider | e }}
+      </a>
+    </p>
+    {% if local_available %}
+    <p class="text-muted small mb-3">or use a local account</p>
+    {% endif %}
+    {% endif %}
+    {% if local_available %}
     <form method="post" action="/login" class="needs-validation" novalidate>
       <input type="hidden" name="next" value="{{ next }}">
       <div class="mb-3">
@@ -19,5 +30,8 @@
       <button type="submit" class="btn btn-primary">Sign in</button>
     </form>
     <p class="mt-3 mb-0 text-muted small">API clients can use <code>POST /token</code> (OAuth2 password) instead.</p>
+    {% elif not oauth_available %}
+    <p class="text-muted mb-0">No sign-in methods are configured.</p>
+    {% endif %}
   </div>
 </div>
