From 187989cc1d1b38eea7d6c66378fdcc9b7893d402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?= Date: Mon, 30 Mar 2026 00:35:53 +0200 Subject: [PATCH] feat: pass invite token through OIDC flow to allow invited registration When registration is disabled, users with a valid invite link can now register via OIDC/SSO. The invite token is passed from the login page through the OIDC state, validated on callback, and used to bypass the allow_registration check. Invite usage count is incremented after successful registration. --- client/src/pages/LoginPage.tsx | 4 ++-- server/src/routes/oidc.ts | 22 +++++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 933995c..0a26e2b 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -497,7 +497,7 @@ export default function LoginPage(): React.ReactElement { {error} )} - {t('common.or')}
-
{ } }, AUTH_CODE_CLEANUP); -const pendingStates = new Map(); +const pendingStates = new Map(); setInterval(() => { const now = Date.now(); @@ -104,8 +104,9 @@ router.get('/login', async (req: Request, res: Response) => { const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol; const host = (req.headers['x-forwarded-host'] as string) || req.headers.host; const redirectUri = `${proto}://${host}/api/auth/oidc/callback`; + const inviteToken = req.query.invite as string | undefined; - pendingStates.set(state, { createdAt: Date.now(), redirectUri }); + pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken }); const params = new URLSearchParams({ response_type: 'code', @@ -194,7 +195,16 @@ router.get('/callback', async (req: Request, res: Response) => { const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; const isFirstUser = userCount === 0; - if (!isFirstUser) { + let validInvite: any = null; + if (pending.inviteToken) { + validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken); + if (validInvite) { + if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null; + if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null; + } + } + + if (!isFirstUser && !validInvite) { const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; if (setting?.value === 'false') { return res.redirect(frontendUrl('/login?oidc_error=registration_disabled')); @@ -214,6 +224,12 @@ router.get('/callback', async (req: Request, res: Response) => { 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)' ).run(username, email, hash, role, sub, config.issuer); + if (validInvite) { + db.prepare( + 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)' + ).run(validInvite.id); + } + user = { id: Number(result.lastInsertRowid), username, email, role } as User; }