feat: SSO

This commit is contained in:
2026-05-28 23:59:14 +00:00
parent fc5699a04c
commit 7526736e80
9 changed files with 442 additions and 27 deletions
+12
View File
@@ -10,6 +10,18 @@
- `NAME`: Organisation name displayed in header (default: JDB-NET) - `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo) - `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Optional external sign-in (Logto)
External sign-in is **disabled and hidden** unless all three variables below are set. Self-hosted deployments should leave these unset so users cannot use the operator's identity provider.
- `LOGTO_OIDC_ENDPOINT`: Logto instance URL (e.g. `https://sso.jdbnet.co.uk`)
- `LOGTO_APP_ID`: Traditional web application client ID (OIDC login flow)
- `LOGTO_APP_SECRET`: Traditional web application client secret
When configured, enable external sign-in per deployment from **Users → SSO** (admin only). Pre-provisioned users are redirected to Logto after entering their email, where they can sign in with **Microsoft 365** or **Discord**. Local password and TOTP authentication remain available when external sign-in is disabled or the user is not pre-provisioned.
**Operator setup (managed deployments only):** Configure Microsoft (multi-tenant) and Discord connectors once in Logto Console. Register each IPAM instance redirect URI on the Logto application: `https://{host}/api/v2/auth/sso/callback`. Ensure the **email** scope is enabled on the Logto application.
### Database Setup ### Database Setup
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate
+153 -16
View File
@@ -11,7 +11,7 @@ import logging
from io import StringIO, BytesIO from io import StringIO, BytesIO
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from urllib.parse import urlparse from urllib.parse import urlencode, urlparse
from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
import pyotp import pyotp
@@ -40,8 +40,19 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET') app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png') app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png')
app.config['VERSION'] = os.environ.get('VERSION', 'unknown') app.config['VERSION'] = os.environ.get('VERSION', 'unknown')
app.config['LOGTO_OIDC_ENDPOINT'] = os.environ.get('LOGTO_OIDC_ENDPOINT', '')
app.config['LOGTO_APP_ID'] = os.environ.get('LOGTO_APP_ID', '')
app.config['LOGTO_APP_SECRET'] = os.environ.get('LOGTO_APP_SECRET', '')
from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key from db import (
init_db, hash_password, get_db_connection, verify_password, generate_api_key,
get_app_settings, update_app_settings,
)
from logto_client import (
logto_sso_available, init_logto_oauth, oauth, extract_logto_email,
)
init_logto_oauth(app)
# ── TOTP / 2FA helpers ─────────────────────────────────────────────────────── # ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
def generate_totp_secret(): def generate_totp_secret():
@@ -155,7 +166,7 @@ def get_user_from_api_key(api_key):
return None return None
def establish_user_session(user_id, conn=None): def establish_user_session(user_id, conn=None, via_sso=False):
"""Populate session after successful authentication.""" """Populate session after successful authentication."""
close_conn = False close_conn = False
if conn is None: if conn is None:
@@ -170,6 +181,10 @@ def establish_user_session(user_id, conn=None):
session['permissions'] = list(load_permissions_for_user(user_id, conn)) session['permissions'] = list(load_permissions_for_user(user_id, conn))
session['logged_in'] = True session['logged_in'] = True
session['user_id'] = user_id session['user_id'] = user_id
session['auth_via_sso'] = bool(via_sso)
if via_sso:
session.pop('pending_user_id', None)
session.pop('pending_email', None)
session.modified = True session.modified = True
finally: finally:
if close_conn: if close_conn:
@@ -251,6 +266,37 @@ def require_auth(f):
return decorated_function return decorated_function
def require_sso_available(f):
"""Decorator: SSO subsystem must be enabled via environment variables."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not logto_sso_available():
abort(404)
return f(*args, **kwargs)
return decorated_function
def _sso_oidc_callback_url():
return f"{request.scheme}://{request.host}/api/v2/auth/sso/callback"
def _me_payload(user=None):
payload = {
'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
'features': {'sso': logto_sso_available()},
}
if user:
payload.update({
'logged_in': True,
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
'permissions': sorted(user.get('permissions') or []),
})
else:
payload['logged_in'] = False
return payload
def json_body(): def json_body():
return request.get_json(silent=True) or {} return request.get_json(silent=True) or {}
@@ -1216,6 +1262,76 @@ def group_devices_by_site(devices):
# ── Auth & account (v2) ─────────────────────────────────────────────────────── # ── Auth & account (v2) ───────────────────────────────────────────────────────
@app.route('/api/v2/auth/login/precheck', methods=['POST'])
def api_auth_login_precheck():
data = json_body()
email = (data.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'Email required'}), 400
if not logto_sso_available():
return jsonify({'method': 'local'})
with get_db_connection(current_app) as conn:
settings = get_app_settings(conn)
if not settings.get('is_sso_enabled'):
return jsonify({'method': 'local'})
cursor = conn.cursor()
cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,))
if not cursor.fetchone():
return jsonify({'method': 'local'})
params = {'email': email}
redirect_target = (data.get('redirect') or '').strip()
if redirect_target:
params['redirect'] = redirect_target
return jsonify({
'method': 'sso',
'redirect_url': f"/api/v2/auth/sso/login?{urlencode(params)}",
})
@app.route('/api/v2/auth/sso/login', methods=['GET'])
@require_sso_available
def api_auth_sso_login():
email = (request.args.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'Email required'}), 400
redirect_target = (request.args.get('redirect') or '').strip()
if redirect_target:
session['sso_post_login_redirect'] = redirect_target
session['sso_expected_email'] = email
session.modified = True
redirect_uri = _sso_oidc_callback_url()
return oauth.logto.authorize_redirect(redirect_uri, login_hint=email)
@app.route('/api/v2/auth/sso/callback', methods=['GET'])
@require_sso_available
def api_auth_sso_callback():
try:
token = oauth.logto.authorize_access_token()
except Exception as exc:
logging.exception('Logto OIDC callback failed')
return redirect('/login?error=sso_failed')
email = extract_logto_email(oauth.logto, token)
if not email:
return redirect('/login?error=sso_no_email')
expected = (session.pop('sso_expected_email', '') or '').strip().lower()
if expected and email != expected:
logging.warning('SSO email mismatch: expected %s got %s', expected, email)
return redirect('/login?error=sso_email_mismatch')
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,))
row = cursor.fetchone()
if not row:
return redirect('/login?error=sso_user_not_found')
user_id = row[0]
establish_user_session(user_id, conn=conn, via_sso=True)
add_audit_log(user_id, 'sso_login', f'SSO login for {email}', conn=conn)
logging.info('User %s logged in via SSO.', email)
redirect_to = session.pop('sso_post_login_redirect', None) or '/'
return redirect(redirect_to)
@app.route('/api/v2/auth/login', methods=['POST']) @app.route('/api/v2/auth/login', methods=['POST'])
def api_auth_login(): def api_auth_login():
data = json_body() data = json_body()
@@ -1261,19 +1377,7 @@ def api_auth_logout():
@app.route('/api/v2/auth/me', methods=['GET']) @app.route('/api/v2/auth/me', methods=['GET'])
def api_auth_me(): def api_auth_me():
user = resolve_auth() user = resolve_auth()
if not user: return jsonify(_me_payload(user))
return jsonify({
'logged_in': False,
'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
})
return jsonify({
'logged_in': True,
'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
'permissions': sorted(user.get('permissions') or []),
})
@app.route('/api/v2/auth/verify-2fa', methods=['POST']) @app.route('/api/v2/auth/verify-2fa', methods=['POST'])
@@ -1442,6 +1546,39 @@ def api_account_regenerate_backup_codes():
return jsonify({'backup_codes': backup_codes}) return jsonify({'backup_codes': backup_codes})
def _sso_settings_response(settings):
return {
'is_sso_enabled': bool(settings.get('is_sso_enabled')),
}
@app.route('/api/v2/settings/sso', methods=['GET'])
@require_sso_available
@require_permission('view_admin')
def api_settings_sso_get():
with get_db_connection(current_app) as conn:
settings = get_app_settings(conn)
return jsonify(_sso_settings_response(settings))
@app.route('/api/v2/settings/sso', methods=['POST'])
@require_sso_available
@require_permission('view_admin')
def api_settings_sso_post():
data = json_body()
is_sso_enabled = bool(data.get('is_sso_enabled'))
with get_db_connection(current_app) as conn:
update_app_settings(conn, is_sso_enabled=is_sso_enabled)
settings = get_app_settings(conn)
add_audit_log(
get_current_user_id(),
'sso_settings_update',
f"External sign-in {'enabled' if is_sso_enabled else 'disabled'}",
conn=conn,
)
return jsonify(_sso_settings_response(settings))
# ── API v2 routes ───────────────────────────────────────────────────────────── # ── API v2 routes ─────────────────────────────────────────────────────────────
@app.route('/api/v2/info', methods=['GET']) @app.route('/api/v2/info', methods=['GET'])
+31
View File
@@ -540,6 +540,16 @@ def init_db(app=None):
logging.info("Database indexes created successfully") logging.info("Database indexes created successfully")
cursor.execute('''
CREATE TABLE IF NOT EXISTS AppSettings (
id INTEGER PRIMARY KEY,
is_sso_enabled BOOLEAN DEFAULT FALSE
)
''')
cursor.execute('SELECT id FROM AppSettings WHERE id = 1')
if not cursor.fetchone():
cursor.execute('INSERT INTO AppSettings (id) VALUES (1)')
run_v2_migrations(cursor, conn) run_v2_migrations(cursor, conn)
conn.commit() conn.commit()
@@ -596,3 +606,24 @@ def run_v2_migrations(cursor, conn):
logging.info("Removed orphaned permission: %s", perm_name) logging.info("Removed orphaned permission: %s", perm_name)
logging.info("v2 database migrations complete") logging.info("v2 database migrations complete")
def get_app_settings(conn):
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT is_sso_enabled FROM AppSettings WHERE id = 1')
row = cursor.fetchone()
if not row:
return {'is_sso_enabled': False}
return row
def update_app_settings(conn, **fields):
allowed = {'is_sso_enabled'}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return
columns = ', '.join(f'{k} = %s' for k in updates)
values = list(updates.values())
cursor = conn.cursor()
cursor.execute(f'UPDATE AppSettings SET {columns} WHERE id = 1', values)
conn.commit()
+24
View File
@@ -26,6 +26,11 @@ export interface MeResponse {
org?: { name: string; logo: string }; org?: { name: string; logo: string };
user?: { id: number; name: string; email: string }; user?: { id: number; name: string; email: string };
permissions?: string[]; permissions?: string[];
features?: { sso?: boolean };
}
export interface SsoSettings {
is_sso_enabled: boolean;
} }
export interface Device { export interface Device {
@@ -158,6 +163,15 @@ export const api = {
}), }),
); );
}, },
async loginPrecheck(email: string, redirect?: string) {
return handle<{ method: "local" | "sso"; redirect_url?: string }>(
await fetchApi("/api/v2/auth/login/precheck", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ email, redirect }),
}),
);
},
async verify2fa(code: string, useBackup = false) { async verify2fa(code: string, useBackup = false) {
return handle(await fetchApi("/api/v2/auth/verify-2fa", { return handle(await fetchApi("/api/v2/auth/verify-2fa", {
method: "POST", method: "POST",
@@ -177,6 +191,16 @@ export const api = {
async logout() { async logout() {
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" })); return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
}, },
async ssoSettings() {
return handle<SsoSettings>(await fetchApi("/api/v2/settings/sso"));
},
async saveSsoSettings(body: { is_sso_enabled: boolean }) {
return handle<SsoSettings>(await fetchApi("/api/v2/settings/sso", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(body),
}));
},
async dashboard() { async dashboard() {
return handle<{ return handle<{
stats: { stats: {
+2
View File
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore("auth", {
permissions: [] as string[], permissions: [] as string[],
org: { name: "IPAM", logo: "" }, org: { name: "IPAM", logo: "" },
version: "unknown", version: "unknown",
features: { sso: false },
}), }),
getters: { getters: {
can: (state) => (perm: string) => state.permissions.includes(perm), can: (state) => (perm: string) => state.permissions.includes(perm),
@@ -22,6 +23,7 @@ export const useAuthStore = defineStore("auth", {
this.permissions = data.permissions ?? []; this.permissions = data.permissions ?? [];
this.org = data.org ?? this.org; this.org = data.org ?? this.org;
this.version = data.app_version ?? "unknown"; this.version = data.app_version ?? "unknown";
this.features = data.features ?? { sso: false };
}, },
async login(email: string, password: string) { async login(email: string, password: string) {
return api.login(email, password); return api.login(email, password);
+64 -7
View File
@@ -1,17 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { api } from "@/api";
const SSO_ERRORS: Record<string, string> = {
sso_failed: "External sign-in failed. Please try again or use your password.",
sso_no_email: "External sign-in did not return an email address. Contact your administrator.",
sso_email_mismatch: "The signed-in account does not match the email you entered.",
sso_user_not_found: "No account exists for that email. Contact your administrator.",
};
const email = ref(""); const email = ref("");
const password = ref(""); const password = ref("");
const err = ref(""); const err = ref("");
const busy = ref(false); const busy = ref(false);
const step = ref<"email" | "password">("email");
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
async function submit() { onMounted(() => {
const code = route.query.error;
if (typeof code === "string" && code in SSO_ERRORS) {
err.value = SSO_ERRORS[code];
}
});
async function continueWithEmail() {
err.value = "";
busy.value = true;
try {
const redirect = (route.query.redirect as string) || undefined;
const r = await api.loginPrecheck(email.value.trim(), redirect);
if (r.method === "sso" && r.redirect_url) {
window.location.href = r.redirect_url;
return;
}
step.value = "password";
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
async function submitPassword() {
err.value = ""; err.value = "";
busy.value = true; busy.value = true;
try { try {
@@ -32,23 +66,46 @@ async function submit() {
busy.value = false; busy.value = false;
} }
} }
function backToEmail() {
step.value = "email";
password.value = "";
err.value = "";
}
</script> </script>
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-surface p-6"> <div class="flex min-h-screen items-center justify-center bg-surface p-6">
<div class="card w-full max-w-md p-8"> <div class="card w-full max-w-md p-8">
<h1 class="text-2xl font-semibold">Sign in</h1> <h1 class="text-2xl font-semibold">Sign in</h1>
<p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p> <p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p>
<form class="mt-8 space-y-4" @submit.prevent="submit"> <form class="mt-8 space-y-4" @submit.prevent="step === 'email' ? continueWithEmail() : submitPassword()">
<div> <div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label> <label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
<input v-model="email" type="email" class="input-field" required autocomplete="username" /> <input
v-model="email"
type="email"
class="input-field"
required
autocomplete="username"
:readonly="step === 'password'"
/>
</div> </div>
<div> <div v-if="step === 'password'">
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label> <label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" /> <input v-model="password" type="password" class="input-field" required autocomplete="current-password" autofocus />
</div> </div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p> <p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button> <button type="submit" class="btn-primary w-full" :disabled="busy">
{{ busy ? "Please wait…" : step === "email" ? "Continue" : "Sign in" }}
</button>
<button
v-if="step === 'password'"
type="button"
class="btn-secondary w-full"
@click="backToEmail"
>
Back
</button>
</form> </form>
</div> </div>
</div> </div>
+77 -3
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed, watch } from "vue";
import { api, type UserRow, type RoleRow } from "@/api"; import { api, type UserRow, type RoleRow, type SsoSettings } from "@/api";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const tab = ref<"users" | "roles">("users"); const tab = ref<"users" | "roles" | "sso">("users");
const showSsoTab = computed(() => auth.features?.sso && auth.can("view_admin"));
const users = ref<UserRow[]>([]); const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]); const roles = ref<RoleRow[]>([]);
const permissions = ref<{ id: number; name: string; category?: string }[]>([]); const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
@@ -19,6 +20,46 @@ const editRoleId = ref<number | null>(null);
const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] }); const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
const showApiKey = ref(""); const showApiKey = ref("");
const ssoSettings = ref<SsoSettings | null>(null);
const ssoLoading = ref(false);
const ssoErr = ref("");
const ssoMsg = ref("");
const ssoForm = ref({ is_sso_enabled: false });
async function loadSso() {
if (!showSsoTab.value) return;
ssoLoading.value = true;
ssoErr.value = "";
try {
ssoSettings.value = await api.ssoSettings();
ssoForm.value = {
is_sso_enabled: ssoSettings.value.is_sso_enabled,
};
} catch (e) {
ssoErr.value = e instanceof Error ? e.message : "Failed to load SSO settings";
} finally {
ssoLoading.value = false;
}
}
watch(tab, (value) => {
if (value === "sso") loadSso();
});
async function saveSso() {
ssoErr.value = "";
ssoMsg.value = "";
try {
ssoSettings.value = await api.saveSsoSettings({
is_sso_enabled: ssoForm.value.is_sso_enabled,
});
ssoMsg.value = "SSO settings saved.";
} catch (e) {
ssoErr.value = e instanceof Error ? e.message : "Failed to save SSO settings";
}
}
const permByCategory = computed(() => { const permByCategory = computed(() => {
const m: Record<string, typeof permissions.value> = {}; const m: Record<string, typeof permissions.value> = {};
for (const p of permissions.value) { for (const p of permissions.value) {
@@ -132,6 +173,12 @@ async function delRole(id: number) {
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button> <button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button> <button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button>
<button
v-if="showSsoTab"
class="rounded-lg px-3 py-1 text-sm"
:class="tab === 'sso' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'"
@click="tab = 'sso'"
>SSO</button>
</div> </div>
<section v-if="tab === 'users'" class="mt-8"> <section v-if="tab === 'users'" class="mt-8">
@@ -184,6 +231,33 @@ async function delRole(id: number) {
</ul> </ul>
</section> </section>
<section v-if="tab === 'sso' && showSsoTab" class="mt-8 space-y-6">
<div>
<h2 class="font-semibold text-accent">Single sign-on</h2>
<p class="mt-1 text-sm text-slate-500">External sign-in for pre-provisioned users.</p>
</div>
<p v-if="ssoLoading" class="text-slate-500">Loading</p>
<template v-else>
<form class="card space-y-4" @submit.prevent="saveSso">
<h3 class="font-medium">External sign-in</h3>
<label class="flex items-center gap-2 text-sm">
<input v-model="ssoForm.is_sso_enabled" type="checkbox" />
Enable external sign-in for this deployment
</label>
<p class="text-sm text-slate-500">
When enabled, pre-provisioned users can sign in via <strong>Microsoft 365</strong> or
<strong>Discord</strong> through our identity provider. Local password login remains
available for users not routed to external sign-in.
</p>
<button type="submit" class="btn-primary text-sm">Save</button>
</form>
<p v-if="ssoMsg" class="text-sm text-emerald-600">{{ ssoMsg }}</p>
<p v-if="ssoErr" class="text-sm text-red-500">{{ ssoErr }}</p>
</template>
</section>
<div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false"> <div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser"> <form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser">
<h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2> <h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2>
+76
View File
@@ -0,0 +1,76 @@
"""Logto OIDC helpers for external sign-in."""
import logging
import os
from authlib.integrations.flask_client import OAuth
REQUIRED_LOGTO_VARS = (
'LOGTO_OIDC_ENDPOINT',
'LOGTO_APP_ID',
'LOGTO_APP_SECRET',
)
oauth = OAuth()
def logto_sso_available() -> bool:
return all(os.environ.get(k) for k in REQUIRED_LOGTO_VARS)
def _endpoint() -> str:
return os.environ['LOGTO_OIDC_ENDPOINT'].rstrip('/')
def init_logto_oauth(app):
"""Register Authlib OAuth client when Logto env vars are present."""
oauth.init_app(app)
if not logto_sso_available():
return
endpoint = _endpoint()
oauth.register(
name='logto',
client_id=app.config['LOGTO_APP_ID'],
client_secret=app.config['LOGTO_APP_SECRET'],
server_metadata_url=f'{endpoint}/oidc/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'},
)
def extract_logto_email(oauth_client, token) -> str | None:
"""Resolve the user's email from Logto token claims or the userinfo endpoint."""
if not isinstance(token, dict):
return None
def normalize(value) -> str | None:
if not value or not isinstance(value, str):
return None
normalized = value.strip().lower()
return normalized if '@' in normalized else None
userinfo = token.get('userinfo')
for source in (userinfo, token):
if not source:
continue
email = normalize(source.get('email'))
if email:
return email
email = normalize(source.get('preferred_username'))
if email:
return email
try:
fetched = oauth_client.userinfo(token=token)
email = normalize(fetched.get('email'))
if email:
return email
email = normalize(fetched.get('preferred_username'))
if email:
return email
logging.warning(
'Logto userinfo did not include an email claim (scope=%s)',
token.get('scope'),
)
except Exception:
logging.exception('Failed to fetch Logto userinfo')
return None
+2
View File
@@ -4,3 +4,5 @@ dotenv
gunicorn gunicorn
pyotp pyotp
qrcode[pil] qrcode[pil]
authlib
requests