OAuth2 OpenID Connect provider support etc. #3

Merged
LeoVasanko merged 65 commits from oidconnect into main 2026-02-18 02:40:27 +00:00
12 changed files with 65 additions and 66 deletions
Showing only changes of commit 99893fbb62 - Show all commits
+6 -6
View File
@@ -57,11 +57,11 @@ async function handleDelete(credential) {
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
@@ -78,7 +78,7 @@ async function handleTerminateSession(session) {
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
@@ -232,7 +232,7 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
+9 -9
View File
@@ -341,22 +341,22 @@ const handleDelete = async (credential) => {
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const paskiaVersion = computed(() => authStore.settings?.version || '')
const sessions = computed(() => authStore.userInfo?.sessions || [])
const sessions = computed(() => authStore.userInfo?.sessions || {})
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { await authStore.terminateSession(sessionId) }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
@@ -368,12 +368,12 @@ const isAdmin = computed(() => {
const perms = authStore.ctx?.permissions
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const credentials = computed(() => authStore.userInfo?.credentials ? Object.values(authStore.userInfo.credentials) : [])
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of sessions.value) {
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
+22 -17
View File
@@ -6,7 +6,7 @@
</div>
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<template v-if="sessionsArray.length">
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
@@ -17,10 +17,10 @@
<div class="session-list">
<div
v-for="session in group.sessions"
:key="session.id"
:key="session.key"
:class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession?.id === session.id,
'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential
}]"
tabindex="-1"
@@ -34,14 +34,14 @@
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
:disabled="isTerminating(session.key)"
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1"
></button>
</div>
@@ -70,7 +70,7 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
@@ -108,7 +108,7 @@ const handleCardClick = (event) => {
}
}
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
@@ -151,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
if (!isTerminating(session.key)) emit('terminate', session)
})
if (event.defaultPrevented) return
@@ -210,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current)
const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null
})
@@ -220,15 +225,15 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => {
const groups = {}
for (const session of props.sessions) {
const key = session.client || session.host || ''
if (!groups[key]) {
groups[key] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || key }
for (const session of sessionsArray.value) {
const groupKey = session.client || session.host || ''
if (!groups[groupKey]) {
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[key].sessions.push(session)
if (session.is_current_host) groups[key].isCurrentSite = true
groups[groupKey].sessions.push(session)
if (session.is_current_host) groups[groupKey].isCurrentSite = true
}
for (const key in groups) groups[key].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
+2 -2
View File
@@ -104,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo()
},
async terminateSession(sessionId) {
async terminateSession(sessionKey) {
try {
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
if (payload?.current_session_terminated) {
sessionStorage.clear()
location.reload()
+3 -2
View File
@@ -12,6 +12,7 @@ from datetime import UTC, datetime, timedelta
from uuid import UUID
import uuid7
import base64url
from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import (
@@ -588,7 +589,7 @@ def login(
session = Session.create(
user=user_uuid,
credential=credential_uuid,
key=hash_secret("cookie", token),
key=base64url.enc(hash_secret("cookie", token)),
host=host,
ip=ip,
user_agent=user_agent,
@@ -656,7 +657,7 @@ def create_credential_session(
# Generate token and derive key
token = secrets.token_urlsafe(12)
key = hash_secret("cookie", token)
key = base64url.enc(hash_secret("cookie", token))
session = Session.create(
user=user_uuid,
+6 -5
View File
@@ -7,6 +7,7 @@ from uuid import UUID
import msgspec
import uuid7
import base64url
from paskia import db
from paskia.util import hostutil
@@ -383,7 +384,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
def __post_init__(self):
if not hasattr(self, "key"):
self.key: bytes = b""
self.key: str = ""
@property
def user(self) -> User:
@@ -423,7 +424,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
cls,
user: UUID | User,
credential: UUID | Credential,
key: bytes,
key: str,
host: str,
ip: str,
user_agent: str,
@@ -433,7 +434,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Create a new Session with the provided key.
Args:
key: The hashed session key (derived from secret via hash_secret)
key: The base64url-encoded hashed session key (derived from secret via hash_secret then base64url.enc)
Returns:
Session object with key set
@@ -625,7 +626,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {}
credentials: dict[UUID, Credential] = {}
sessions: dict[bytes, Session] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {}
# OIDC provider data
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
@@ -669,7 +670,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = hash_secret("cookie", session_secret)
key = base64url.enc(hash_secret("cookie", session_secret))
try:
s = self.sessions[key]
except KeyError:
+5 -8
View File
@@ -564,15 +564,15 @@ async def admin_get_user_detail(
)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
sessions = [
ApiUserSession.from_db(
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=auth,
current_key=ctx.session.key,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
]
}
return MsgspecResponse(
ApiUserDetail(
@@ -716,10 +716,7 @@ async def admin_delete_user_session(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
try:
session_key = base64url.dec(session_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid session ID format")
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != user_uuid:
+1 -2
View File
@@ -223,8 +223,7 @@ async def api_user_info(
return MsgspecResponse(
await userinfo.build_user_info(
user_uuid=ctx.user.uuid,
auth=auth,
session_record=ctx.session,
session_key=ctx.session.key,
request_host=request.headers.get("host"),
ctx=ctx,
)
+4 -4
View File
@@ -41,7 +41,7 @@ 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)
key = base64url.enc(hash_secret("oidc", token))
s = db.data().sessions.get(key)
if not s or s.client_uuid is None:
return None
@@ -57,7 +57,7 @@ def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session
continue
if client_uuid is not None and s.client_uuid != client_uuid:
continue
if hash_secret("oidc", s.key) == sid:
if base64url.dec(s.key) == sid:
return s
return None
@@ -235,7 +235,7 @@ async def _handle_authorization_code(
)
# Derive sid from session key
sid = base64url.enc(hash_secret("oidc", session.key))
sid = session.key
return _build_token_response(
request,
@@ -312,7 +312,7 @@ 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))
sid_str = session.key
return _build_token_response(
request,
+1 -4
View File
@@ -158,10 +158,7 @@ async def api_delete_session(
status_code=401, detail="Session expired", mode="login"
)
try:
session_key = base64url.dec(session_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid session ID format")
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != ctx.user.uuid:
+1 -1
View File
@@ -122,7 +122,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
user: ApiUser
credentials: dict[UUID, Credential]
aaguid_info: dict[str, ApiAaguidInfo]
sessions: list[ApiUserSession]
sessions: dict[bytes, ApiUserSession]
permissions: dict[UUID, ApiPermission] = {}
org: ApiOrg | None = None
role: ApiRole | None = None
+5 -6
View File
@@ -37,8 +37,7 @@ def build_session_context(ctx: SessionContext) -> ApiSessionContext:
async def build_user_info(
*,
user_uuid,
auth: str,
session_record,
session_key: str,
request_host: str | None,
ctx: SessionContext | None = None,
) -> ApiUserDetail:
@@ -46,15 +45,15 @@ async def build_user_info(
user = db.data().users[user_uuid]
normalized_host = hostutil.normalize_host(request_host)
sessions = [
ApiUserSession.from_db(
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=auth,
current_key=session_key,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
]
}
return ApiUserDetail(
user=ApiUser.from_db(user),