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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ tech changes will usually be stripped from release notes for the public
- Notes:
- Campaigns tab (see more general note changes below)
- Create can be confirmed with the enter key
- Added OIDC Authentication to Client and Server, based on original changes from JatinVasman PR
- This uses Authorization Code Flow with optional PKCE

### Changed

Expand Down
1 change: 1 addition & 0 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="PA-signup" content="true" />
<meta name="PA-mail" content="true" />
<meta name="PA-auth" content="local" />
<script type="importmap">
{
"imports": {
Expand Down
61 changes: 56 additions & 5 deletions client/src/auth/Login.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, onBeforeMount } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useToast } from "vue-toastification";
Expand Down Expand Up @@ -31,6 +31,13 @@ const resetPending = ref(false);
const showLanguageDropdown = ref(false);
const allowRegister = (document.querySelector("meta[name='PA-signup']")?.getAttribute("content") ?? "true") === "true";
const hasMail = (document.querySelector("meta[name='PA-mail']")?.getAttribute("content") ?? "true") === "true";
const authMethods = (document.querySelector("meta[name='PA-auth']")?.getAttribute("content") ?? "local").split(" ");
const providers = ref<Array<{ display_name: string; provider_id: string }>>([]);
if (!authMethods.includes("local")) {
// If local authentication is disabled
// then we can only do OIDC login so the only valid mode is Login
mode.value = Mode.Login;
}

function getStaticImg(img: string): string {
return baseAdjust(`/static/img/${img}`);
Expand Down Expand Up @@ -97,6 +104,40 @@ async function resetPassword(): Promise<void> {
toast.error(t("auth.login.resetPasswordFailed"));
}
}

async function fetchOidcProviders(): Promise<void> {
const response = await http.get("/api/oidc/providers");
if (response.ok) {
const data = (await response.json()) as { providers: Array<{ display_name: string; provider_id: string }>};
providers.value.push(...data.providers);
} else {
toast.error(await getErrorReason(response));
}
}

async function loginOIDC(providerId: string): Promise<void> {
const loginResponse = await http.postJson("/api/oidc/login", {
provider_name: providerId
});
if (loginResponse.ok) {
const loginData = (await loginResponse.json()) as { authorization_url: string };
// Check if the value is valid
if (loginData.authorization_url) {
window.location.href = loginData.authorization_url;
} else {
toast.error("Invalid authorization URL received from server.");
}
} else {
toast.error(await getErrorReason(loginResponse));
}
}

onBeforeMount(async () => {
if (authMethods.includes("oidc")) {
await fetchOidcProviders();
}
});

</script>

<template>
Expand Down Expand Up @@ -125,7 +166,7 @@ async function resetPassword(): Promise<void> {
<LanguageDropdown v-if="showLanguageDropdown" id="language-dropdown" />
</div>
</div>
<div class="form-row">
<div v-if="authMethods.includes('local')" class="form-row">
<label for="username">{{ t("common.username") }}</label>
<input
id="username"
Expand All @@ -136,7 +177,7 @@ async function resetPassword(): Promise<void> {
autofocus
/>
</div>
<div class="form-row">
<div v-if="authMethods.includes('local')" class="form-row">
<div style="display: flex; flex-direction: column; gap: 0.5rem">
<label for="password">{{ t("common.password") }}</label>
<span v-if="hasMail" class="forgot-password note" @click="mode = Mode.ForgotPassword">
Expand All @@ -151,14 +192,24 @@ async function resetPassword(): Promise<void> {
required
/>
</div>
<button type="submit" @click="login">
<button v-if="authMethods.includes('local')" type="submit" @click="login">
<img :src="getStaticImg('check_small.svg')" />
{{ t("auth.login.login") }}
</button>
<button v-if="allowRegister" type="button" @click="mode = Mode.Register">
<button v-if="allowRegister && authMethods.includes('local')" type="button" @click="mode = Mode.Register">
<img :src="getStaticImg('plus.svg')" />
{{ t("auth.login.register") }}
</button>
<div v-if="authMethods.includes('oidc')">
<div style="text-align: center; margin-bottom: 1rem;">{{ t("auth.login.oidc_login") }}</div>
<div id="oidc-buttons">
<div v-for="item in providers" :key="item.provider_id">
<button type="button" @click="loginOIDC(item.provider_id)">
{{ t("auth.login.oidc_with") }} {{ item.display_name }}
Comment thread
Kruptein marked this conversation as resolved.
</button>
</div>
</div>
</div>
</template>
<template v-else-if="mode === Mode.Register">
<div id="title">
Expand Down
4 changes: 3 additions & 1 deletion client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@
"resetPassword": "Reset password",
"resetPasswordSuccess": "Password reset",
"resetPasswordFailed": "Failed to reset password - Token incorrect or expired",
"resetPasswordNote": "Enter your new password below and click submit to reset your password."
"resetPasswordNote": "Enter your new password below and click submit to reset your password.",
"oidc_login": "Login with OpenID Connect",
"oidc_with": "Login with"
}
},
"core": {
Expand Down
94 changes: 93 additions & 1 deletion server/src/api/http/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import random
import secrets
import logging

from aiohttp import web
from aiohttp_security import authorized_userid, forget, remember
Expand All @@ -11,7 +13,7 @@
from ...db.models.user import User
from ...mail import send_mail
from ...state.auth import auth_state

from .oidc import oidc_auth

async def is_authed(request):
user = await get_authorized_user(request)
Expand All @@ -25,6 +27,8 @@ async def is_authed(request):


async def login(request):
if "local" not in cfg().general.authentication_methods:
return web.HTTPForbidden(reason="Local authentication is disabled")
try:
user = await get_authorized_user(request)
except web.HTTPUnauthorized:
Expand Down Expand Up @@ -83,6 +87,8 @@ async def logout(request):


async def forgot_password(request):
if "local" not in cfg().general.authentication_methods:
return web.HTTPForbidden(reason="Local authentication is disabled")
data = await request.json()
email = data["email"]
user = User.by_email(email)
Expand Down Expand Up @@ -110,6 +116,8 @@ async def forgot_password(request):


async def reset_password(request):
if "local" not in cfg().general.authentication_methods:
return web.HTTPForbidden(reason="Local authentication is disabled")
data = await request.json()
token = data["token"]
password = data["password"]
Expand All @@ -123,3 +131,87 @@ async def reset_password(request):
user.save()

return web.HTTPOk()

# OIDC Authentication endpoints

async def oidc_providers(request):
if "oidc" not in cfg().general.authentication_methods:
return web.HTTPForbidden(reason="OIDC authentication is disabled")
providers = oidc_auth.get_providers()
return web.json_response({"providers": providers})

async def oidc_login(request):
logger = logging.getLogger("PlanarAllyServer")
Comment thread
nwhansen marked this conversation as resolved.
if "oidc" not in cfg().general.authentication_methods:
logger.warning(f"OIDC authentication attempted but disabled in config")
return web.HTTPForbidden(reason="OIDC authentication is disabled")
try:
data = await request.json()
except Exception as e:
return web.HTTPBadRequest(reason="Invalid request format")

# Grab the state from the browser's request ensuring we can validate it later
provider_name = data.get("provider_name")
if not provider_name:
return web.HTTPBadRequest(reason="Missing required parameters")

auth_url = await oidc_auth.get_authorization_url(provider_name)

if not auth_url:
return web.HTTPInternalServerError(reason="Failed to initiate OIDC login")
logger.debug(f"Redirecting to OIDC provider with URL: {auth_url}")
# Instruct the client to redirect to the auth_url
return web.json_response({"authorization_url": auth_url})

async def oidc_callback(request):
logger = logging.getLogger("PlanarAllyServer")
logger.debug("OIDC callback invoked")
if "oidc" not in cfg().general.authentication_methods:
logger.warning(f"OIDC authentication attempted but disabled in config")
return web.HTTPForbidden(reason="OIDC authentication is disabled")

# Get the provider name from the URL path since we have multiple providers
# and cannot rely on a single endpoint
provider_name = request.match_info["provider"]
code = request.query.get("code")
if not code or not provider_name:
logger.error("Missing code or provider_name in OIDC callback request")
return web.HTTPBadRequest(reason="Missing required parameters")

try:
user_info = await oidc_auth.exchange_code_for_token(code, provider_name, request.query.get("state"))
if not user_info:
logger.error("Failed to retrieve user info from OIDC provider")
return web.HTTPUnauthorized(reason="OIDC authentication failed")


if not user_info.username or not user_info.email:
logger.error("OIDC user info missing username/email")
return web.HTTPUnauthorized(reason="OIDC authentication failed")

user = User.by_name(user_info.username) or User.by_email(user_info.email)
if user is None:
Comment on lines +192 to +193
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not ideal, it's perfectly possible for a regular user to choose a particular username, and then someone else logging in with an OIDC provider that happens to give out the same username, in which case you'll log in as the existing user.

we're going to have to modify the User table so that OIDC users cannot be mistaken for regular users. A simple approach would be to add a new column1, but I think we're better off with more drastic changes.

A main User table which would only have the essentials for interacting with PA once authenticated. (e.g. last_login / colour_history / asset links / ...) and separate tables for auth purposes (e.g. UserAuthPassword and UserAuthOidc) which contain a link to the user and any relevant data that is required for the relevant auth flow.2

It would then probably also be wise to introduce a display_name field on the main User table, that we can use to populate the OIDC initial username and still allow the user to modify it to something they prefer.

This is a bigger modification, so if you don't feel up for it, I don't mind doing this work myself, and you'll just have to rebase/merge my this PR with my changes.

Footnotes

  1. Although this would still pose some issues as the name field is UNIQUE currently.

  2. This would also remove the need to generate random passwords that will never be used.

Copy link
Copy Markdown
Contributor Author

@nwhansen nwhansen Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is an excellent point, my perspective was an OIDC provider that I also own, so the conflict wasn't even on my radar. I can try to take a crack at the SQL side of this, although I would have to get up to speed quite quickly on how you manage the sqllite db.

I'll fix the issues mentioned here, so at least i can re-base my changes. I like the direction your describing. One item that might need some more work is the "Admin" user concept. Either it would need to be fixed against Local Authentication or it needs to also indicate the auth provider it needs.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll manage the user sql rework and good point on the admin table

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started the SQL rework, I won't be offended if you finish it first.

I was intimated by the save.py file. However the database management system is starting to make sense. I will create an issue to discuss what I got up to and maybe we can catch any other changes that make sense

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah feel free to finish it as well, I haven't started on it myself anyway as I had some other migrations to finish first :D

Don't hesitate to reach out either here or on discord if you need help/clarifications!

The save file is purely for database migration, you have to up the SAVE_VERSION and write a migration in pure SQL.

# Check if auto-signup is allowed
if not cfg().general.allow_signups:
logger.info(f"Auto-signup disabled, rejecting OIDC login for unknown user: {user_info.username}")
return web.HTTPForbidden(reason="User does not exist and auto-signup is disabled")
# Auto-register the user
with db.atomic():
# Generate a sufficiently random password, since it won't be used for login
password = secrets.token_urlsafe(16)
user = User.create_new(user_info.username, password, user_info.email)
stats.events.user_created(user.id)
logger.info(f"Auto-registered new user: {user_info.username}")
response = web.HTTPOk()
user.update_last_login()
await remember(request, response, user_info.username)
logger.info(f"User {user_info.username} logged in via OIDC")
# Convert this response into a redirect for the browser
# we need the the auth system to modify the response headers
response.headers["Location"] = f"{cfg().general.client_url}"
response.set_status(302)
return response

except Exception as e:
logger.error(f"Error during OIDC callback processing: {e}")
return web.HTTPInternalServerError(reason="An error occurred during OIDC authentication")
Loading
Loading