Feat/sso #3
No files matched your search
@@ -19,6 +19,7 @@
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"nodemailer": "10.0.11",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"qrcode": "1.5.4",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { settingsRoutes } from './routes/settings.ts';
|
||||
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { authRoutes } from './routes/auth.ts';
|
||||
import { userRoutes } from './routes/users.ts';
|
||||
import { oidcRoutes } from './routes/oidc.ts';
|
||||
import { systemRoutes } from './routes/system.ts';
|
||||
import { extensionRoutes } from './routes/extensions.ts';
|
||||
import { trunkRoutes } from './routes/trunks.ts';
|
||||
@@ -139,6 +140,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
|
||||
app.register(authRoutes);
|
||||
app.register(userRoutes);
|
||||
app.register(oidcRoutes);
|
||||
app.register(systemRoutes);
|
||||
app.register(extensionRoutes);
|
||||
app.register(trunkRoutes);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { users } from '../db/schema.ts';
|
||||
import { forbidden, unauthorized } from '../errors.ts';
|
||||
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
|
||||
import { getTokenUser } from './tokens.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
@@ -39,7 +39,7 @@ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Pro
|
||||
if (found.extended) setSessionCookie(reply, token!);
|
||||
req.user = found.user;
|
||||
req.sessionId = found.sessionId;
|
||||
if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
|
||||
if (mustChange(found) && !req.url.startsWith('/api/auth/'))
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,23 @@ function recent(key: string, windowMs: number, now: number): number[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
|
||||
/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */
|
||||
export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean {
|
||||
return (
|
||||
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
|
||||
(email !== null &&
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max)
|
||||
);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
|
||||
export function recordLoginFailure(ip: string, email: string | null, now = Date.now()): void {
|
||||
// Forget keys past every window, at most once a minute: emails tried once and never again
|
||||
// would otherwise stay until restart (08 §6).
|
||||
if (now - lastSweep > MIN) {
|
||||
lastSweep = now;
|
||||
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
|
||||
}
|
||||
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
|
||||
for (const key of [`ip:${ip}`, ...(email === null ? [] : [`email:${email.toLowerCase()}`])]) {
|
||||
failures.set(key, [...(failures.get(key) ?? []), now]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const COOKIE = 'sp_session';
|
||||
@@ -33,6 +34,8 @@ export async function createSession(
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
method: 'password' | 'ldap' | 'oidc' = 'password',
|
||||
/** OpenID sessions (12 §3.5): kept encrypted for the leaver check and signing out there. */
|
||||
idp?: { refreshToken: string | null; idToken: string },
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.insert(sessions).values({
|
||||
@@ -42,6 +45,9 @@ export async function createSession(
|
||||
ip,
|
||||
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||
method,
|
||||
idpRefreshEnc: idp?.refreshToken ? encrypt(idp.refreshToken) : null,
|
||||
idpIdTokenEnc: idp ? encrypt(idp.idToken) : null,
|
||||
idpCheckedAt: idp ? new Date() : null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
@@ -53,7 +59,7 @@ export async function createSession(
|
||||
export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
const id = sha256(token);
|
||||
const [row] = await db
|
||||
.select({ expiresAt: sessions.expiresAt, user: users })
|
||||
.select({ expiresAt: sessions.expiresAt, method: sessions.method, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
|
||||
@@ -66,9 +72,13 @@ export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
.where(eq(sessions.id, id));
|
||||
extended = true;
|
||||
}
|
||||
return { user: row.user, sessionId: id, extended };
|
||||
return { user: row.user, sessionId: id, method: row.method, extended };
|
||||
}
|
||||
|
||||
/** A password to change only blocks sessions made with it (OpenID sessions skip it, 12). */
|
||||
export const mustChange = (f: { user: { mustChangePassword: boolean }; method: string }) =>
|
||||
f.user.mustChangePassword && f.method === 'password';
|
||||
|
||||
export async function deleteSession(db: Db, token: string) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
|
||||
import { COOKIE, getSessionUser } from '../auth/sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange } from '../auth/sessions.ts';
|
||||
import { extensions } from '../db/schema.ts';
|
||||
import type { LiveState } from './state.ts';
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
for (const c of clients)
|
||||
void getSessionUser(app.db, c.token, false)
|
||||
.then((f) => {
|
||||
if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
|
||||
if (!f || mustChange(f) || (f.user.role === 'admin') !== c.admin)
|
||||
c.ws.close(4401, 'unauthorized');
|
||||
})
|
||||
.catch(() => {}); // database down: keep the socket, check again next time
|
||||
@@ -123,7 +123,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
const token = req.cookies[COOKIE];
|
||||
const found = token ? await getSessionUser(app.db, token) : null;
|
||||
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
|
||||
if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
|
||||
if (!found || mustChange(found)) return ws.close(4401, 'unauthorized');
|
||||
const mine = await app.db
|
||||
.select({ n: extensions.number })
|
||||
.from(extensions)
|
||||
|
||||
+35
-10
@@ -24,7 +24,8 @@ import {
|
||||
otpauthUrl,
|
||||
} from '../auth/totp.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, userIdentities, users } from '../db/schema.ts';
|
||||
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import {
|
||||
unauthorized,
|
||||
@@ -55,12 +56,12 @@ import {
|
||||
/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
|
||||
export const signInWays = (
|
||||
u: { passwordHash: string | null },
|
||||
ids: { kind: 'oidc' | 'ldap'; email: string | null }[],
|
||||
ids: { kind: 'oidc' | 'ldap'; email: string | null; groups: string[] }[],
|
||||
): SignInWays => {
|
||||
const oidc = ids.find((i) => i.kind === 'oidc');
|
||||
return {
|
||||
password: u.passwordHash !== null,
|
||||
oidc: oidc ? { email: oidc.email } : null,
|
||||
oidc: oidc ? { email: oidc.email, groups: oidc.groups } : null,
|
||||
ldap: ids.some((i) => i.kind === 'ldap'),
|
||||
};
|
||||
};
|
||||
@@ -71,7 +72,11 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, u.id));
|
||||
const ids = await db
|
||||
.select({ kind: userIdentities.kind, email: userIdentities.email })
|
||||
.select({
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
groups: userIdentities.groups,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, u.id));
|
||||
return {
|
||||
@@ -203,11 +208,15 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
/** The login page's choices (12 §2). Public: says nothing about any user. */
|
||||
app.get('/api/auth/methods', async (): Promise<AuthMethods> => ({
|
||||
mode: await getSetting(db, 'auth.mode'),
|
||||
ldap: false, // S4
|
||||
oidc: null, // S2
|
||||
}));
|
||||
app.get('/api/auth/methods', async (): Promise<AuthMethods> => {
|
||||
const mode = await getSetting(db, 'auth.mode');
|
||||
const oidc = await getSetting(db, 'auth.oidc');
|
||||
return {
|
||||
mode,
|
||||
ldap: false, // S4
|
||||
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const { login, ...body } = LoginSchema.parse(req.body);
|
||||
@@ -267,8 +276,24 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const [sess] = req.sessionId
|
||||
? await db.select().from(sessions).where(eq(sessions.id, req.sessionId))
|
||||
: [];
|
||||
await deleteSession(db, req.cookies[COOKIE]!);
|
||||
clearSessionCookie(reply);
|
||||
// Signed in with OpenID: sign out there too (12 §3.5). A provider problem doesn't stop ours.
|
||||
let redirect: string | null = null;
|
||||
const oidc = sess?.idpIdTokenEnc ? await getSetting(db, 'auth.oidc') : null;
|
||||
if (oidc)
|
||||
try {
|
||||
redirect = await endSessionUrl(
|
||||
oidc,
|
||||
app.config.PUBLIC_HOSTNAME,
|
||||
decrypt(sess!.idpIdTokenEnc!),
|
||||
);
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'no sign-out at the provider');
|
||||
}
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
@@ -277,7 +302,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
entityId: req.user!.id,
|
||||
summary: `${req.user!.email} logged out`,
|
||||
});
|
||||
return { ok: true };
|
||||
return redirect ? { ok: true, redirect } : { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
|
||||
import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import { HttpError, notFound, unauthorized } from '../errors.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
|
||||
import { createSession, setSessionCookie } from '../auth/sessions.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { decide, verifiedEmail } from '../sso/claims.ts';
|
||||
import {
|
||||
finishSignIn,
|
||||
oidcError,
|
||||
startSignIn,
|
||||
type OidcSettings,
|
||||
type PendingSignIn,
|
||||
} from '../sso/oidc.ts';
|
||||
import { isLastAdmin } from './users.ts';
|
||||
|
||||
export const OIDC_COOKIE = 'sp_oidc';
|
||||
const COOKIE_PATH = '/api/auth/oidc';
|
||||
|
||||
/** Same-site paths only: never send the browser to another origin. */
|
||||
const safeNext = (p: string | undefined) => (p && /^\/(?![/\\])/.test(p) ? p : undefined);
|
||||
|
||||
const withParam = (path: string, k: string, v: string) =>
|
||||
`${path}${path.includes('?') ? '&' : '?'}${k}=${v}`;
|
||||
|
||||
export async function oidcRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
const host = app.config.PUBLIC_HOSTNAME;
|
||||
|
||||
/** The provider's settings, when OpenID is on (12 §1.1: not in `password` mode). */
|
||||
async function enabled(): Promise<OidcSettings> {
|
||||
const s = await getSetting(db, 'auth.oidc');
|
||||
if (!s || (await getSetting(db, 'auth.mode')) === 'password') throw notFound();
|
||||
return s;
|
||||
}
|
||||
|
||||
app.get('/api/auth/oidc/start', async (req, reply) => {
|
||||
const s = await enabled();
|
||||
const q = z
|
||||
.object({ next: z.string().max(500).optional(), connect: z.literal('1').optional() })
|
||||
.parse(req.query);
|
||||
let connectSessionId: string | null = null;
|
||||
let next = safeNext(q.next);
|
||||
if (q.connect) {
|
||||
await requireAuth(req, reply);
|
||||
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
|
||||
connectSessionId = req.sessionId;
|
||||
next ??= req.user!.role === 'admin' ? '/admin/account' : '/me/settings';
|
||||
}
|
||||
try {
|
||||
const { url, pending } = await startSignIn(s, host, next ?? '/', connectSessionId);
|
||||
reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
path: COOKIE_PATH,
|
||||
maxAge: 600,
|
||||
});
|
||||
return reply.redirect(url.href);
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
|
||||
return reply.redirect(
|
||||
withParam(connectSessionId ? next! : '/login', 'sso_error', 'provider'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Not logged by Fastify: the query holds the authorization code (12 §1).
|
||||
app.get('/api/auth/oidc/callback', { logLevel: 'silent' }, async (req, reply) => {
|
||||
const s = await enabled();
|
||||
let pending: PendingSignIn | null = null;
|
||||
try {
|
||||
pending = JSON.parse(decrypt(req.cookies[OIDC_COOKIE] ?? '')) as PendingSignIn;
|
||||
} catch {
|
||||
// missing or not ours: `expired` below
|
||||
}
|
||||
reply.clearCookie(OIDC_COOKIE, { path: COOKIE_PATH });
|
||||
const connecting = pending?.connectSessionId ?? null;
|
||||
|
||||
const fail = async (code: string, reason: string, userId?: string) => {
|
||||
if (!connecting) recordLoginFailure(req.ip, null);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip, reason }, 'OpenID sign-in failed');
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: req.ip,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Failed ${s.label} sign-in (${code}: ${reason.slice(0, 200)})`,
|
||||
});
|
||||
return reply.redirect(withParam(connecting ? pending!.next : '/login', 'sso_error', code));
|
||||
};
|
||||
|
||||
if (!connecting && isLoginBlocked(req.ip, null)) return fail('too_many', 'rate limited');
|
||||
if (!pending || pending.exp < Date.now()) return fail('expired', 'no sign-in in progress');
|
||||
const q = req.query as Record<string, unknown>;
|
||||
if (typeof q.error === 'string')
|
||||
return fail(q.error === 'access_denied' ? 'denied' : 'provider', `provider: ${q.error}`);
|
||||
|
||||
let r;
|
||||
try {
|
||||
r = await finishSignIn(s, new URL(`https://${host}${req.url}`), pending);
|
||||
} catch (err) {
|
||||
return fail('provider', oidcError(err));
|
||||
}
|
||||
const d = decide(s, r.claims);
|
||||
const email = typeof r.claims.email === 'string' ? r.claims.email : null;
|
||||
if (!d.allowed) return fail('not_allowed', `${email ?? r.subject} not in the allowed groups`);
|
||||
const [owner] = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
|
||||
|
||||
if (connecting) {
|
||||
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
|
||||
const [sess] = await db
|
||||
.select({ userId: sessions.userId, email: users.email })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.id, connecting),
|
||||
gt(sessions.expiresAt, new Date()),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!sess) return fail('expired', 'the session that asked has ended');
|
||||
if (owner && owner.userId !== sess.userId)
|
||||
return fail('taken', `${email ?? r.subject} belongs to another user`, sess.userId);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, sess.userId), eq(userIdentities.kind, 'oidc')));
|
||||
await tx.insert(userIdentities).values({
|
||||
userId: sess.userId,
|
||||
kind: 'oidc',
|
||||
subject: r.subject,
|
||||
email,
|
||||
groups: d.groups,
|
||||
});
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: sess.userId,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_connected',
|
||||
entityType: 'user',
|
||||
entityId: sess.userId,
|
||||
summary: `${sess.email} connected ${s.label} account ${email ?? r.subject}`,
|
||||
});
|
||||
return reply.redirect(withParam(pending.next, 'sso', 'connected'));
|
||||
}
|
||||
|
||||
let userId = owner?.userId;
|
||||
if (!userId) {
|
||||
// A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
|
||||
const v = verifiedEmail(r.claims, s.trustEmail);
|
||||
const [u] = v
|
||||
? await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, v),
|
||||
isNull(users.passwordHash),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: userIdentities.id })
|
||||
.from(userIdentities)
|
||||
.where(
|
||||
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (!u) return fail('not_connected', `${email ?? r.subject} isn't connected to a user`);
|
||||
await db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: u.id, kind: 'oidc', subject: r.subject, email, groups: d.groups });
|
||||
await audit(db, {
|
||||
actorUserId: u.id,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_connected',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${v} connected ${s.label} by verified email`,
|
||||
});
|
||||
userId = u.id;
|
||||
}
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user!.isActive) return fail('disabled', `${user!.email} is disabled`, userId);
|
||||
|
||||
const role = d.admin === null ? user!.role : d.admin ? 'admin' : 'user';
|
||||
if (role !== user!.role) {
|
||||
if (role === 'user' && (await isLastAdmin(db, userId)))
|
||||
req.log.warn(
|
||||
{ userId },
|
||||
`kept the last admin with a password, though ${s.label} says user`,
|
||||
);
|
||||
else {
|
||||
await db.update(users).set({ role }).where(eq(users.id, userId));
|
||||
await audit(db, {
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `${user!.email} is now ${role}, from ${s.label}'s groups`,
|
||||
diff: { before: { role: user!.role }, after: { role } },
|
||||
});
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email, groups: d.groups, lastUsedAt: new Date() })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
const token = await createSession(
|
||||
db,
|
||||
userId,
|
||||
req.ip,
|
||||
req.headers['user-agent'] ?? null,
|
||||
'oidc',
|
||||
{ refreshToken: r.refreshToken, idToken: r.idToken },
|
||||
);
|
||||
setSessionCookie(reply, token);
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId));
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: req.ip,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `${user!.email} signed in with ${s.label}`,
|
||||
});
|
||||
req.log.info({ event: 'auth.oidc', userId }, 'OpenID sign-in');
|
||||
return reply.redirect(pending.next);
|
||||
});
|
||||
|
||||
/** Disconnect (12 §3.3): refused when it would leave them no way in under the current mode. */
|
||||
async function disconnect(req: FastifyRequest, reply: FastifyReply, userId: string) {
|
||||
const [u] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!u) throw notFound('User not found');
|
||||
const ids = await db
|
||||
.select({ kind: userIdentities.kind })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, userId));
|
||||
if (!ids.some((i) => i.kind === 'oidc')) throw notFound('No OpenID account is connected');
|
||||
const mode = await getSetting(db, 'auth.mode');
|
||||
const passwordWay = u.passwordHash !== null || ids.some((i) => i.kind === 'ldap');
|
||||
if (!passwordWay || (mode === 'oidc' && u.role !== 'admin'))
|
||||
throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, 'oidc')));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_disconnected',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Disconnected the OpenID account of ${u.email}`,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
}
|
||||
|
||||
app.delete('/api/me/identities/oidc', { preHandler: requireAuth }, (req, reply) =>
|
||||
disconnect(req, reply, req.user!.id),
|
||||
);
|
||||
app.delete('/api/users/:id/identities/oidc', { preHandler: requireAdmin }, (req, reply) =>
|
||||
disconnect(req, reply, z.object({ id: z.uuid() }).parse(req.params).id),
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
AiEndpointSchema,
|
||||
AiTtsEndpointSchema,
|
||||
ContrackInputSchema,
|
||||
OidcInputSchema,
|
||||
SettingsSchemas,
|
||||
SmtpInputSchema,
|
||||
type AiTestResult,
|
||||
type AuthMode,
|
||||
type Destination,
|
||||
type OidcInput,
|
||||
type OidcTestResult,
|
||||
type SettingKey,
|
||||
type SettingsDto,
|
||||
type Warning,
|
||||
@@ -30,6 +34,7 @@ import { detectPublicIp } from '../jobs.ts';
|
||||
import type { SendMail } from '../services/mail.ts';
|
||||
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
|
||||
import { forgetBook } from '../contacts/contrack.ts';
|
||||
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -47,9 +52,20 @@ const READ_ONLY = new Set<string>([
|
||||
]);
|
||||
|
||||
async function toDto(db: Db): Promise<SettingsDto> {
|
||||
const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
|
||||
const { smtp, 'contacts.contrack': ctk, 'auth.oidc': oidc, ...rest } = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
'auth.oidc': oidc && {
|
||||
label: oidc.label,
|
||||
issuer: oidc.issuer,
|
||||
clientId: oidc.clientId,
|
||||
scopes: oidc.scopes,
|
||||
groupsClaim: oidc.groupsClaim,
|
||||
adminGroups: oidc.adminGroups,
|
||||
allowedGroups: oidc.allowedGroups,
|
||||
trustEmail: oidc.trustEmail,
|
||||
hasClientSecret: oidc.clientSecretEnc !== null,
|
||||
},
|
||||
'contacts.contrack': ctk && {
|
||||
url: ctk.url,
|
||||
publicUrl: ctk.publicUrl,
|
||||
@@ -100,6 +116,32 @@ async function checkRefs(db: Db, v: Record<string, unknown>) {
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
|
||||
/** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
|
||||
async function oidcFromInput(input: OidcInput | null): Promise<OidcSettings | null> {
|
||||
if (!input) return null;
|
||||
try {
|
||||
assertIssuer(input.issuer);
|
||||
} catch (err) {
|
||||
throw badRequest((err as Error).message);
|
||||
}
|
||||
const old = await getSetting(db, 'auth.oidc');
|
||||
return {
|
||||
label: input.label,
|
||||
issuer: input.issuer,
|
||||
clientId: input.clientId,
|
||||
clientSecretEnc: input.clientSecret
|
||||
? encrypt(input.clientSecret)
|
||||
: input.clearClientSecret
|
||||
? null
|
||||
: (old?.clientSecretEnc ?? null),
|
||||
scopes: input.scopes,
|
||||
groupsClaim: input.groupsClaim,
|
||||
adminGroups: input.adminGroups,
|
||||
allowedGroups: input.allowedGroups,
|
||||
trustEmail: input.trustEmail,
|
||||
};
|
||||
}
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/settings', () => toDto(db));
|
||||
@@ -142,13 +184,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
: (old?.companyTokenEnc ?? ''),
|
||||
};
|
||||
forgetBook(null);
|
||||
} else if (key === 'auth.oidc') {
|
||||
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
}
|
||||
await checkRefs(db, values);
|
||||
// 12 §1.1: mixed and oidc need OpenID Connect set up first.
|
||||
// S2 makes this "auth.oidc set and its Test passed".
|
||||
if (values['auth.mode'] !== undefined && values['auth.mode'] !== 'password')
|
||||
throw badRequest('Set up OpenID Connect first');
|
||||
for (const key of ['ai.llm', 'ai.stt', 'ai.tts'] as const) {
|
||||
const ep = values[key] as { url: string } | null | undefined;
|
||||
try {
|
||||
@@ -159,6 +199,21 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
const before = await getAllSettings(db);
|
||||
// 12 §1.1: mixed and oidc need a working OpenID Connect setup.
|
||||
if ('auth.mode' in values || 'auth.oidc' in values) {
|
||||
const mode = (values['auth.mode'] ?? before['auth.mode']) as AuthMode;
|
||||
const oidc = (
|
||||
'auth.oidc' in values ? values['auth.oidc'] : before['auth.oidc']
|
||||
) as OidcSettings | null;
|
||||
if (mode !== 'password') {
|
||||
if (!oidc) throw badRequest('Set up OpenID Connect first');
|
||||
try {
|
||||
await oidcConfig(oidc);
|
||||
} catch (err) {
|
||||
throw badRequest(`OpenID Connect doesn't work: ${oidcError(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
|
||||
const c = app.config;
|
||||
const [sip, tls] = [
|
||||
@@ -236,6 +291,12 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return { notified };
|
||||
});
|
||||
|
||||
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
|
||||
app.post('/api/settings/auth/oidc/test', async (req): Promise<OidcTestResult> => {
|
||||
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
|
||||
return testOidc(s!, app.config.PUBLIC_HOSTNAME);
|
||||
});
|
||||
|
||||
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
|
||||
app.post('/api/settings/network/detect-public-ip', async () => {
|
||||
const ip = await detectPublicIp();
|
||||
|
||||
@@ -17,10 +17,16 @@ import { hashPassword } from '../auth/passwords.ts';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import { randomAlnum } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { signInWays } from './auth.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
type IdentityRow = { userId: string; kind: 'oidc' | 'ldap'; email: string | null };
|
||||
type IdentityRow = {
|
||||
userId: string;
|
||||
kind: 'oidc' | 'ldap';
|
||||
email: string | null;
|
||||
groups: string[];
|
||||
};
|
||||
|
||||
export const toUserDto = (
|
||||
u: UserRow,
|
||||
@@ -47,6 +53,7 @@ const identitiesOf = (db: Db, userId?: string): Promise<IdentityRow[]> =>
|
||||
userId: userIdentities.userId,
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
groups: userIdentities.groups,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(userId ? eq(userIdentities.userId, userId) : undefined);
|
||||
@@ -75,10 +82,10 @@ async function getUser(db: Db, id: string): Promise<UserRow> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if removing `id` from the active admins would leave none with a password: the way in
|
||||
* when the identity provider is down (12 §1).
|
||||
* Removing `id` from the active admins would leave none with a password: the way in when the
|
||||
* identity provider is down (12 §1).
|
||||
*/
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
export async function isLastAdmin(db: Db, id: string): Promise<boolean> {
|
||||
const [r] = await db
|
||||
.select({ n: count() })
|
||||
.from(users)
|
||||
@@ -90,7 +97,11 @@ async function assertNotLastAdmin(db: Db, id: string) {
|
||||
ne(users.id, id),
|
||||
),
|
||||
);
|
||||
if (!r || r.n === 0) throw conflict('This is the last active admin with a password');
|
||||
return !r || r.n === 0;
|
||||
}
|
||||
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
if (await isLastAdmin(db, id)) throw conflict('This is the last active admin with a password');
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
@@ -124,12 +135,17 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post('/api/users', async (req, reply) => {
|
||||
const body = UserCreateSchema.parse(req.body);
|
||||
const password = temporaryPassword();
|
||||
// OpenID only (12 §3.3): no password; their first sign-in matches them by verified email.
|
||||
const password = (await getSetting(db, 'auth.mode')) === 'oidc' ? null : temporaryPassword();
|
||||
let created: UserRow;
|
||||
try {
|
||||
[created] = (await db
|
||||
.insert(users)
|
||||
.values({ ...body, passwordHash: await hashPassword(password), mustChangePassword: true })
|
||||
.values({
|
||||
...body,
|
||||
passwordHash: password && (await hashPassword(password)),
|
||||
mustChangePassword: password !== null,
|
||||
})
|
||||
.returning()) as [UserRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw conflict('A user with this email already exists');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { claimValues, decide, verifiedEmail } from './claims.ts';
|
||||
|
||||
describe('OpenID claims (12 §3.4)', () => {
|
||||
const keycloak = {
|
||||
groups: ['/pbx-admins', 'pbx-users'],
|
||||
realm_access: { roles: ['offline_access', 'pbx-admin'] },
|
||||
resource_access: { selfphoneng: { roles: ['operator'] } },
|
||||
'urn:zitadel:iam:org:project:roles': { 'pbx-admin': { '123': 'example.com' } },
|
||||
hd: 'example.com',
|
||||
};
|
||||
|
||||
it('reads dotted paths, whole keys first', () => {
|
||||
expect(claimValues(keycloak, 'groups')).toEqual(['/pbx-admins', 'pbx-users']);
|
||||
expect(claimValues(keycloak, 'realm_access.roles')).toContain('pbx-admin');
|
||||
expect(claimValues(keycloak, 'resource_access.selfphoneng.roles')).toEqual(['operator']);
|
||||
expect(claimValues(keycloak, 'urn:zitadel:iam:org:project:roles')).toEqual(['pbx-admin']);
|
||||
expect(claimValues(keycloak, 'hd')).toEqual(['example.com']);
|
||||
expect(claimValues(keycloak, 'missing.path')).toEqual([]);
|
||||
expect(claimValues({ 'a.b': ['x'], a: { b: ['y'] } }, 'a.b')).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('decides allowed and role, comparing names exactly', () => {
|
||||
const rules = { groupsClaim: 'groups', adminGroups: [], allowedGroups: [] };
|
||||
expect(decide(rules, keycloak)).toMatchObject({ allowed: true, admin: null });
|
||||
expect(decide({ ...rules, adminGroups: ['pbx-admins'] }, keycloak).admin).toBe(false);
|
||||
expect(decide({ ...rules, adminGroups: ['/pbx-admins'] }, keycloak).admin).toBe(true);
|
||||
expect(decide({ ...rules, allowedGroups: ['sales'] }, keycloak).allowed).toBe(false);
|
||||
expect(decide({ ...rules, allowedGroups: ['sales', 'pbx-users'] }, keycloak).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes an email only when verified, or trusted', () => {
|
||||
expect(verifiedEmail({ email: 'a@x.com', email_verified: true }, false)).toBe('a@x.com');
|
||||
expect(verifiedEmail({ email: 'a@x.com', email_verified: 'true' }, false)).toBeNull();
|
||||
expect(verifiedEmail({ email: 'a@x.com' }, false)).toBeNull();
|
||||
expect(verifiedEmail({ email: 'a@x.com' }, true)).toBe('a@x.com');
|
||||
expect(verifiedEmail({}, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// OpenID claims → groups, allowed, role (12 §3.4). Pure: no I/O.
|
||||
|
||||
type Claims = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The value at a dotted path. The whole remaining path is tried as one key first, so claim
|
||||
* names with dots or colons (Zitadel's "urn:zitadel:iam:org:project:roles") work, and
|
||||
* "resource_access.selfphoneng.roles" walks down.
|
||||
*/
|
||||
export function claimAt(claims: unknown, path: string): unknown {
|
||||
if (!claims || typeof claims !== 'object') return undefined;
|
||||
const o = claims as Claims;
|
||||
if (path in o) return o[path];
|
||||
const dot = path.indexOf('.');
|
||||
return dot < 0 ? undefined : claimAt(o[path.slice(0, dot)], path.slice(dot + 1));
|
||||
}
|
||||
|
||||
/** A string → [it]; an array → its strings; an object → its keys (Zitadel's roles). */
|
||||
export function claimValues(claims: unknown, path: string): string[] {
|
||||
const v = claimAt(claims, path);
|
||||
if (typeof v === 'string') return [v];
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
|
||||
if (v && typeof v === 'object') return Object.keys(v);
|
||||
return [];
|
||||
}
|
||||
|
||||
export type OidcRules = { groupsClaim: string; adminGroups: string[]; allowedGroups: string[] };
|
||||
|
||||
export type ClaimsDecision = {
|
||||
groups: string[];
|
||||
allowed: boolean;
|
||||
/** null: the role is managed in SelfPhoneNG (no adminGroups). */
|
||||
admin: boolean | null;
|
||||
};
|
||||
|
||||
export function decide(rules: OidcRules, claims: Claims): ClaimsDecision {
|
||||
const groups = claimValues(claims, rules.groupsClaim);
|
||||
const any = (list: string[]) => list.some((g) => groups.includes(g));
|
||||
return {
|
||||
groups,
|
||||
allowed: rules.allowedGroups.length === 0 || any(rules.allowedGroups),
|
||||
admin: rules.adminGroups.length === 0 ? null : any(rules.adminGroups),
|
||||
};
|
||||
}
|
||||
|
||||
/** The email, when the provider says it's verified (or the admin trusts its emails, 12 §3.3). */
|
||||
export function verifiedEmail(claims: Claims, trustEmail: boolean): string | null {
|
||||
const email = typeof claims.email === 'string' ? claims.email.trim() : '';
|
||||
if (!email) return null;
|
||||
return claims.email_verified === true || trustEmail ? email : null;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// OpenID Connect relying party (12 §3) on openid-client: discovery, PKCE + state + nonce, the
|
||||
// code grant, userinfo, test, signing out at the provider. No protocol code of our own.
|
||||
import * as oidc from 'openid-client';
|
||||
import type { OidcTestResult, SettingValue } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
|
||||
export type OidcSettings = NonNullable<SettingValue<'auth.oidc'>>;
|
||||
|
||||
/** http:// only for the fake provider in tests; a real provider is always https (12 §3.1). */
|
||||
const insecureOk = (u: URL) =>
|
||||
process.env.NODE_ENV === 'test' && ['127.0.0.1', 'localhost'].includes(u.hostname);
|
||||
|
||||
export function assertIssuer(issuer: string): void {
|
||||
const u = new URL(issuer);
|
||||
if (u.protocol !== 'https:' && !insecureOk(u)) throw new Error('The issuer must be https://');
|
||||
}
|
||||
|
||||
export const redirectUri = (host: string) => `https://${host}/api/auth/oidc/callback`;
|
||||
export const postLogoutUri = (host: string) => `https://${host}/login`;
|
||||
|
||||
let cached: { key: string; config: oidc.Configuration } | null = null;
|
||||
|
||||
/**
|
||||
* Discovers the provider once per settings value (a change or a failure means the next call
|
||||
* discovers again) and picks client_secret_basic, else _post, else none for a public client.
|
||||
*/
|
||||
export async function oidcConfig(s: OidcSettings): Promise<oidc.Configuration> {
|
||||
const key = JSON.stringify(s);
|
||||
if (cached?.key === key) return cached.config;
|
||||
const issuer = new URL(s.issuer);
|
||||
assertIssuer(s.issuer);
|
||||
const insecure = insecureOk(issuer);
|
||||
const found = await oidc.discovery(issuer, s.clientId, undefined, oidc.None(), {
|
||||
timeout: 5,
|
||||
...(insecure && { execute: [oidc.allowInsecureRequests] }),
|
||||
});
|
||||
const meta = found.serverMetadata();
|
||||
const secret = s.clientSecretEnc ? decrypt(s.clientSecretEnc) : null;
|
||||
const methods = meta.token_endpoint_auth_methods_supported ?? ['client_secret_basic'];
|
||||
const auth = !secret
|
||||
? oidc.None()
|
||||
: methods.includes('client_secret_basic') || !methods.includes('client_secret_post')
|
||||
? oidc.ClientSecretBasic(secret)
|
||||
: oidc.ClientSecretPost(secret);
|
||||
const config = new oidc.Configuration(meta, s.clientId, undefined, auth);
|
||||
config.timeout = 5;
|
||||
// Check ID token signatures too (by default TLS to the token endpoint is trusted instead).
|
||||
oidc.enableNonRepudiationChecks(config);
|
||||
if (insecure) oidc.allowInsecureRequests(config);
|
||||
cached = { key, config };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** What the sp_oidc cookie holds between start and callback (12 §3.2). */
|
||||
export type PendingSignIn = {
|
||||
state: string;
|
||||
nonce: string;
|
||||
verifier: string;
|
||||
next: string;
|
||||
/** Connecting from My account: the session that asked (12 §3.3). */
|
||||
connectSessionId: string | null;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export async function startSignIn(
|
||||
s: OidcSettings,
|
||||
host: string,
|
||||
next: string,
|
||||
connectSessionId: string | null,
|
||||
): Promise<{ url: URL; pending: PendingSignIn }> {
|
||||
const config = await oidcConfig(s);
|
||||
const pending: PendingSignIn = {
|
||||
state: oidc.randomState(),
|
||||
nonce: oidc.randomNonce(),
|
||||
verifier: oidc.randomPKCECodeVerifier(),
|
||||
next,
|
||||
connectSessionId,
|
||||
exp: Date.now() + 600_000,
|
||||
};
|
||||
const url = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri(host),
|
||||
scope: s.scopes,
|
||||
state: pending.state,
|
||||
nonce: pending.nonce,
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(pending.verifier),
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return { url, pending };
|
||||
}
|
||||
|
||||
export type SignInResult = {
|
||||
/** "<iss> <sub>" (12 §2). */
|
||||
subject: string;
|
||||
/** The ID token's claims, then userinfo's on top. */
|
||||
claims: Record<string, unknown>;
|
||||
idToken: string;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
/** Checks the provider's answer (state, PKCE, nonce, ID token) and reads userinfo. */
|
||||
export async function finishSignIn(
|
||||
s: OidcSettings,
|
||||
currentUrl: URL,
|
||||
pending: PendingSignIn,
|
||||
): Promise<SignInResult> {
|
||||
const config = await oidcConfig(s);
|
||||
const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.verifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
const id = tokens.claims()!;
|
||||
let claims: Record<string, unknown> = { ...id };
|
||||
// Several providers put email or groups only in userinfo (12 §3.2 step 4).
|
||||
if (config.serverMetadata().userinfo_endpoint)
|
||||
claims = { ...claims, ...(await oidc.fetchUserInfo(config, tokens.access_token, id.sub)) };
|
||||
return {
|
||||
subject: `${id.iss} ${id.sub}`,
|
||||
claims,
|
||||
idToken: tokens.id_token!,
|
||||
refreshToken: tokens.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** RP-initiated logout (12 §3.5): where to send the browser, or null when the provider can't. */
|
||||
export async function endSessionUrl(
|
||||
s: OidcSettings,
|
||||
host: string,
|
||||
idToken: string,
|
||||
): Promise<string | null> {
|
||||
const config = await oidcConfig(s);
|
||||
if (!config.serverMetadata().end_session_endpoint) return null;
|
||||
return oidc
|
||||
.buildEndSessionUrl(config, {
|
||||
id_token_hint: idToken,
|
||||
post_logout_redirect_uri: postLogoutUri(host),
|
||||
})
|
||||
.toString();
|
||||
}
|
||||
|
||||
/** A provider error as one line for the log and the admin (never a token). */
|
||||
export function oidcError(err: unknown): string {
|
||||
if (err instanceof oidc.ResponseBodyError)
|
||||
return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
|
||||
if (err instanceof oidc.AuthorizationResponseError)
|
||||
return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
|
||||
return (err as Error).message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Sign-in → Test (12 §3.1): discovery, then a code grant with a code that can't
|
||||
* exist: `invalid_grant` means the client id and secret were accepted.
|
||||
*/
|
||||
export async function testOidc(s: OidcSettings, host: string): Promise<OidcTestResult> {
|
||||
try {
|
||||
cached = null;
|
||||
const config = await oidcConfig(s);
|
||||
const meta = config.serverMetadata();
|
||||
try {
|
||||
await oidc.genericGrantRequest(config, 'authorization_code', {
|
||||
code: 'selfphoneng-test-not-a-code',
|
||||
redirect_uri: redirectUri(host),
|
||||
code_verifier: oidc.randomPKCECodeVerifier(),
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof oidc.ResponseBodyError ? err.error : null;
|
||||
if (code === 'invalid_client' || code === 'unauthorized_client')
|
||||
return { ok: false, error: `The provider refused the client ID or secret (${code})` };
|
||||
if (code !== 'invalid_grant' && code !== 'invalid_request')
|
||||
return { ok: false, error: `The token endpoint answered: ${oidcError(err)}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
issuer: meta.issuer,
|
||||
algorithms: meta.id_token_signing_alg_values_supported ?? [],
|
||||
pkce: meta.code_challenge_methods_supported?.includes('S256') ?? false,
|
||||
refreshTokens: meta.grant_types_supported?.includes('refresh_token') ?? false,
|
||||
endSession: Boolean(meta.end_session_endpoint),
|
||||
redirectUri: redirectUri(host),
|
||||
postLogoutRedirectUri: postLogoutUri(host),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: oidcError(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A small OpenID provider for tests (12 §6): discovery, JWKS, authorize, token (code + refresh,
|
||||
// PKCE S256, client_secret_basic), userinfo, end session. `tamper` breaks one thing at a time.
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
export type FakeUser = {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
|
||||
groups?: string[];
|
||||
};
|
||||
|
||||
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
|
||||
|
||||
export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
|
||||
const pair =
|
||||
alg === 'RS256'
|
||||
? generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const other = generateKeyPairSync('rsa', { modulusLength: 2048 }); // signs "bad signature" tokens
|
||||
const clientId = 'selfphoneng';
|
||||
const clientSecret = 'fake-secret';
|
||||
const st = {
|
||||
user: { sub: 'u-1', email: 'ann@example.com', email_verified: true } as FakeUser,
|
||||
tamper: {} as {
|
||||
nonce?: boolean;
|
||||
iss?: boolean;
|
||||
aud?: boolean;
|
||||
expired?: boolean;
|
||||
signature?: boolean;
|
||||
userinfoSub?: boolean;
|
||||
},
|
||||
/** false: refresh tokens are refused, as for a user disabled at the provider. */
|
||||
refreshOk: true,
|
||||
refreshes: 0,
|
||||
};
|
||||
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
|
||||
const access = new Map<string, FakeUser>();
|
||||
const refresh = new Map<string, FakeUser>();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, issuer);
|
||||
const json = (code: number, body: unknown) => {
|
||||
res.writeHead(code, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (url.pathname === '/.well-known/openid-configuration')
|
||||
return json(200, {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
userinfo_endpoint: `${issuer}/userinfo`,
|
||||
jwks_uri: `${issuer}/jwks`,
|
||||
end_session_endpoint: `${issuer}/logout`,
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [alg],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||
});
|
||||
if (url.pathname === '/jwks')
|
||||
return json(200, {
|
||||
keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }],
|
||||
});
|
||||
if (url.pathname === '/authorize') {
|
||||
const p = url.searchParams;
|
||||
if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256')
|
||||
return json(400, { error: 'invalid_request' });
|
||||
const code = b64u(randomBytes(16));
|
||||
codes.set(code, {
|
||||
nonce: p.get('nonce')!,
|
||||
challenge: p.get('code_challenge')!,
|
||||
user: { ...st.user },
|
||||
});
|
||||
const back = new URL(p.get('redirect_uri')!);
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', p.get('state')!);
|
||||
res.writeHead(302, { location: back.href });
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
|
||||
const [id, secret] = Buffer.from(
|
||||
(req.headers.authorization ?? '').replace(/^Basic /, ''),
|
||||
'base64',
|
||||
)
|
||||
.toString()
|
||||
.split(':')
|
||||
.map((x) => decodeURIComponent(x.replace(/\+/g, ' ')));
|
||||
if (id !== clientId || secret !== clientSecret) return json(401, { error: 'invalid_client' });
|
||||
const form = new URLSearchParams(await body(req));
|
||||
if (form.get('grant_type') === 'refresh_token') {
|
||||
const user = refresh.get(form.get('refresh_token')!);
|
||||
st.refreshes++;
|
||||
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
|
||||
return json(200, tokens(user, null));
|
||||
}
|
||||
const c = codes.get(form.get('code') ?? '');
|
||||
codes.delete(form.get('code') ?? '');
|
||||
const verifier = form.get('code_verifier') ?? '';
|
||||
if (!c || b64u(createHash('sha256').update(verifier).digest()) !== c.challenge)
|
||||
return json(400, { error: 'invalid_grant' });
|
||||
return json(200, tokens(c.user, c.nonce));
|
||||
}
|
||||
if (url.pathname === '/userinfo') {
|
||||
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
|
||||
if (!user) return json(401, { error: 'invalid_token' });
|
||||
return json(200, { ...user, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
|
||||
}
|
||||
if (url.pathname === '/logout') return json(200, {});
|
||||
json(404, {});
|
||||
});
|
||||
|
||||
function tokens(user: FakeUser, nonce: string | null) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const t = st.tamper;
|
||||
const claims = {
|
||||
iss: t.iss ? 'https://evil.example' : issuer,
|
||||
sub: user.sub,
|
||||
aud: t.aud ? 'another-client' : clientId,
|
||||
iat: now,
|
||||
exp: t.expired ? now - 3600 : now + 300,
|
||||
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
|
||||
...(user.email && { email: user.email, email_verified: user.email_verified }),
|
||||
};
|
||||
const at = b64u(randomBytes(16));
|
||||
const rt = b64u(randomBytes(16));
|
||||
access.set(at, user);
|
||||
refresh.set(rt, user);
|
||||
return {
|
||||
access_token: at,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 300,
|
||||
refresh_token: rt,
|
||||
id_token: jwt(claims, t.signature ? other.privateKey : pair.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
function jwt(claims: object, key: KeyObject) {
|
||||
const data = `${b64u(JSON.stringify({ alg, kid: 'k1', typ: 'JWT' }))}.${b64u(JSON.stringify(claims))}`;
|
||||
const sig =
|
||||
alg === 'RS256'
|
||||
? sign('sha256', Buffer.from(data), key)
|
||||
: sign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' });
|
||||
return `${data}.${b64u(sig)}`;
|
||||
}
|
||||
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
st,
|
||||
close: () => new Promise<void>((r) => server.close(() => r())),
|
||||
};
|
||||
}
|
||||
|
||||
async function body(req: IncomingMessage): Promise<string> {
|
||||
let s = '';
|
||||
for await (const chunk of req) s += chunk;
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S2: OpenID Connect sign-in, connect, disconnect, modes and signing out, against a fake
|
||||
// provider (test/fake-oidc.ts).
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { sessions, userIdentities, users } from '../src/db/schema.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import type { OidcSettings } from '../src/sso/oidc.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
import { startFakeOidc } from './fake-oidc.ts';
|
||||
|
||||
for (const alg of ['RS256', 'ES256'] as const)
|
||||
describe.skipIf(!hasTestDb)(`OpenID Connect (12 S2, ${alg})`, () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let idp: Awaited<ReturnType<typeof startFakeOidc>>;
|
||||
let admin: string;
|
||||
let oidcSettings: OidcSettings;
|
||||
|
||||
beforeAll(async () => {
|
||||
idp = await startFakeOidc(alg);
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
oidcSettings = {
|
||||
label: 'Keycloak',
|
||||
issuer: idp.issuer,
|
||||
clientId: idp.clientId,
|
||||
clientSecretEnc: encrypt(idp.clientSecret),
|
||||
scopes: 'openid email profile',
|
||||
groupsClaim: 'groups',
|
||||
adminGroups: [],
|
||||
allowedGroups: [],
|
||||
trustEmail: false,
|
||||
};
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
await setSetting(t.db, 'auth.mode', 'mixed');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
await idp?.close();
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetLoginLimiter();
|
||||
idp.st.tamper = {};
|
||||
idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true };
|
||||
});
|
||||
|
||||
/** The browser's round trip: start → provider → callback. */
|
||||
async function flow(cookie?: string, query = '') {
|
||||
const start = await t.app.inject({
|
||||
url: `/api/auth/oidc/start${query}`,
|
||||
headers: cookie ? { cookie } : {},
|
||||
});
|
||||
expect(start.statusCode).toBe(302);
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
|
||||
if (!pending) return { start, callback: null };
|
||||
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
|
||||
const back = new URL(atIdp.headers.get('location')!);
|
||||
expect(back.origin).toBe('https://pbx.test');
|
||||
const callback = await t.app.inject({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
|
||||
});
|
||||
return { start, callback };
|
||||
}
|
||||
const session = (r: { cookies: { name: string; value: string }[] }) => {
|
||||
const c = r.cookies.find((x) => x.name === 'sp_session');
|
||||
return c ? `sp_session=${c.value}` : null;
|
||||
};
|
||||
const errorOf = (r: { headers: Record<string, unknown> }) =>
|
||||
new URL(r.headers.location as string, 'https://pbx.test').searchParams.get('sso_error');
|
||||
|
||||
it('methods show the button in mixed mode', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/methods' });
|
||||
expect(r.json()).toEqual({ mode: 'mixed', ldap: false, oidc: { label: 'Keycloak' } });
|
||||
});
|
||||
|
||||
it('starts with PKCE S256, state and nonce, and a short-lived cookie', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/oidc/start?next=/me/calls' });
|
||||
const to = new URL(r.headers.location as string);
|
||||
expect(to.origin).toBe(idp.issuer);
|
||||
for (const p of ['state', 'nonce', 'code_challenge'])
|
||||
expect(to.searchParams.get(p)).toBeTruthy();
|
||||
expect(to.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(to.searchParams.get('redirect_uri')).toBe('https://pbx.test/api/auth/oidc/callback');
|
||||
expect(r.cookies.find((c) => c.name === 'sp_oidc')).toMatchObject({
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
path: '/api/auth/oidc',
|
||||
maxAge: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('an account nobody connected is refused, and no user is made', async () => {
|
||||
idp.st.user = { sub: 'stranger', email: 'stranger@example.com', email_verified: true };
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('not_connected');
|
||||
expect(session(callback!)).toBeNull();
|
||||
const [u] = await t.db.select().from(users).where(eq(users.email, 'stranger@example.com'));
|
||||
expect(u).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a user with a password is never matched by email, even verified', async () => {
|
||||
await t.addUser('ann@example.com', 'ann-password-12');
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('not_connected');
|
||||
});
|
||||
|
||||
it('connects from My account, then signs in either way', async () => {
|
||||
const ann = await t.login('ann@example.com', 'ann-password-12');
|
||||
const { callback } = await flow(ann, '?connect=1');
|
||||
expect(callback!.headers.location).toBe('/me/settings?sso=connected');
|
||||
const me = await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } });
|
||||
expect(me.json().user.signIn).toEqual({
|
||||
password: true,
|
||||
oidc: { email: 'ann@example.com', groups: [] },
|
||||
ldap: false,
|
||||
});
|
||||
const signIn = await flow(undefined, '?next=/me/calls');
|
||||
expect(signIn.callback!.headers.location).toBe('/me/calls');
|
||||
const cookie = session(signIn.callback!);
|
||||
expect(cookie).toBeTruthy();
|
||||
const [s] = await t.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.method, 'oidc'))
|
||||
.orderBy(sessions.createdAt);
|
||||
expect(s!.idpRefreshEnc).toBeTruthy();
|
||||
expect(s!.idpIdTokenEnc).toBeTruthy();
|
||||
// …and the password still works.
|
||||
expect(await t.login('ann@example.com', 'ann-password-12')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses to connect an account that's someone else's", async () => {
|
||||
await t.addUser('bob@example.com', 'bob-password-12');
|
||||
const bob = await t.login('bob@example.com', 'bob-password-12');
|
||||
const { callback } = await flow(bob, '?connect=1'); // the fake still signs in as u-1 (Ann)
|
||||
expect(errorOf(callback!)).toBe('taken');
|
||||
});
|
||||
|
||||
it('matches a user without a password once, by verified email', async () => {
|
||||
const [u] = await t.db
|
||||
.insert(users)
|
||||
.values({ email: 'cara@example.com', displayName: 'Cara', passwordHash: null })
|
||||
.returning();
|
||||
idp.st.user = { sub: 'u-cara', email: 'cara@example.com', email_verified: false };
|
||||
expect(errorOf((await flow()).callback!)).toBe('not_connected');
|
||||
idp.st.user.email_verified = true;
|
||||
const { callback } = await flow();
|
||||
expect(session(callback!)).toBeTruthy();
|
||||
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
|
||||
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
|
||||
});
|
||||
|
||||
it('trustEmail matches an unverified email', async () => {
|
||||
await t.db
|
||||
.insert(users)
|
||||
.values({ email: 'dee@example.com', displayName: 'Dee', passwordHash: null });
|
||||
idp.st.user = { sub: 'u-dee', email: 'dee@example.com' };
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, trustEmail: true });
|
||||
try {
|
||||
expect(session((await flow()).callback!)).toBeTruthy();
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, tamper] of [
|
||||
['a wrong nonce', { nonce: true }],
|
||||
['a wrong issuer', { iss: true }],
|
||||
['a wrong audience', { aud: true }],
|
||||
['an expired ID token', { expired: true }],
|
||||
['a bad signature', { signature: true }],
|
||||
['a userinfo subject that differs', { userinfoSub: true }],
|
||||
] as const)
|
||||
it(`refuses ${name}`, async () => {
|
||||
idp.st.tamper = tamper;
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('provider');
|
||||
expect(session(callback!)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a callback without our cookie, or with another state', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/oidc/callback?code=x&state=y' });
|
||||
expect(errorOf(r)).toBe('expired');
|
||||
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
|
||||
const back = new URL(atIdp.headers.get('location')!);
|
||||
back.searchParams.set('state', 'forged');
|
||||
const forged = await t.app.inject({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: `sp_oidc=${pending.value}` },
|
||||
});
|
||||
expect(errorOf(forged)).toBe('provider');
|
||||
});
|
||||
|
||||
it('the provider saying access_denied is "denied"', async () => {
|
||||
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
const r = await t.app.inject({
|
||||
url: '/api/auth/oidc/callback?error=access_denied&state=x',
|
||||
headers: { cookie: `sp_oidc=${pending.value}` },
|
||||
});
|
||||
expect(errorOf(r)).toBe('denied');
|
||||
});
|
||||
|
||||
it('groups: allowedGroups refuses, adminGroups sets the role', async () => {
|
||||
idp.st.user = { sub: 'u-1', email: 'ann@example.com', groups: ['pbx-admins'] };
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
|
||||
expect(errorOf((await flow()).callback!)).toBe('not_allowed');
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
|
||||
try {
|
||||
const { callback } = await flow();
|
||||
const me = await t.app.inject({
|
||||
url: '/api/auth/me',
|
||||
headers: { cookie: session(callback!)! },
|
||||
});
|
||||
expect(me.json().user.role).toBe('admin');
|
||||
const [id] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.subject, `${idp.issuer} u-1`));
|
||||
expect(id!.groups).toEqual(['pbx-admins']);
|
||||
idp.st.user.groups = [];
|
||||
await flow();
|
||||
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
||||
expect(ann!.role).toBe('user');
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
}
|
||||
});
|
||||
|
||||
it('signing out of an OpenID session sends the browser to the provider', async () => {
|
||||
const { callback } = await flow();
|
||||
const out = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { ...H, cookie: session(callback!)! },
|
||||
});
|
||||
const to = new URL(out.json().redirect);
|
||||
expect(to.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
|
||||
expect(to.searchParams.get('id_token_hint')).toBeTruthy();
|
||||
expect(to.searchParams.get('post_logout_redirect_uri')).toBe('https://pbx.test/login');
|
||||
// A password session signs out here only.
|
||||
const pw = await t.login('ann@example.com', 'ann-password-12');
|
||||
const out2 = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { ...H, cookie: pw },
|
||||
});
|
||||
expect(out2.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('OpenID-only mode: passwords for admins only; disconnect keeps a way in', async () => {
|
||||
await setSetting(t.db, 'auth.mode', 'oidc');
|
||||
try {
|
||||
const r = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { login: 'ann@example.com', password: 'ann-password-12' },
|
||||
});
|
||||
expect(r.json().error.code).toBe('password_sign_in_off');
|
||||
const { callback } = await flow();
|
||||
const ann = session(callback!)!;
|
||||
const d = await t.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/me/identities/oidc',
|
||||
headers: { ...H, cookie: ann },
|
||||
});
|
||||
expect(d.statusCode).toBe(409);
|
||||
expect(d.json().error.code).toBe('last_way_in');
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.mode', 'mixed');
|
||||
}
|
||||
// Mixed: Ann still has her password, so she may disconnect; OpenID sessions end.
|
||||
const { callback } = await flow();
|
||||
const ann = session(callback!)!;
|
||||
const [row] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
||||
const d = await t.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${row!.id}/identities/oidc`,
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(d.statusCode).toBe(200);
|
||||
const left = await t.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.userId, row!.id), eq(sessions.method, 'oidc')));
|
||||
expect(left).toEqual([]);
|
||||
expect(
|
||||
(await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } })).statusCode,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it('settings: Test, the secret write-only, and the mode needing a working provider', async () => {
|
||||
const form = {
|
||||
label: 'Keycloak',
|
||||
issuer: idp.issuer,
|
||||
clientId: idp.clientId,
|
||||
scopes: 'openid email profile',
|
||||
groupsClaim: 'groups',
|
||||
adminGroups: [],
|
||||
allowedGroups: [],
|
||||
trustEmail: false,
|
||||
};
|
||||
const req = (method: 'GET' | 'PATCH' | 'POST', url: string, payload?: object) =>
|
||||
t.app.inject({ method, url, payload, headers: { ...H, cookie: admin } });
|
||||
const ok = await req('POST', '/api/settings/auth/oidc/test', form);
|
||||
expect(ok.json()).toMatchObject({
|
||||
ok: true,
|
||||
pkce: true,
|
||||
refreshTokens: true,
|
||||
endSession: true,
|
||||
});
|
||||
const bad = await req('POST', '/api/settings/auth/oidc/test', {
|
||||
...form,
|
||||
clientSecret: 'wrong',
|
||||
});
|
||||
expect(bad.json()).toMatchObject({ ok: false });
|
||||
expect((await req('PATCH', '/api/settings', { 'auth.oidc': form })).statusCode).toBe(200);
|
||||
const got = (await req('GET', '/api/settings')).json()['auth.oidc'];
|
||||
expect(got).toMatchObject({ hasClientSecret: true, label: 'Keycloak' });
|
||||
expect(JSON.stringify(got)).not.toContain('clientSecretEnc');
|
||||
const broken = await req('PATCH', '/api/settings', {
|
||||
'auth.oidc': { ...form, issuer: 'http://127.0.0.1:1' },
|
||||
});
|
||||
expect(broken.statusCode).toBe(400);
|
||||
const remote = await req('PATCH', '/api/settings', {
|
||||
'auth.oidc': { ...form, issuer: 'http://kc.example.com/realms/x' },
|
||||
});
|
||||
expect(remote.json().error.message).toContain('https');
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,11 @@ describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
|
||||
.insert(userIdentities)
|
||||
.values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
|
||||
const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
|
||||
expect(r.json().signIn).toEqual({ password: true, oidc: { email: 'l@kc' }, ldap: false });
|
||||
expect(r.json().signIn).toEqual({
|
||||
password: true,
|
||||
oidc: { email: 'l@kc', groups: [] },
|
||||
ldap: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('auth.mode can only be password until OpenID Connect is set up', async () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
|
||||
import type {
|
||||
AuthMethods,
|
||||
OidcInput,
|
||||
OidcTestResult,
|
||||
AboutDto,
|
||||
SystemEventDto,
|
||||
SystemStatus,
|
||||
@@ -163,20 +166,25 @@ export const authApi = {
|
||||
api<{ ok: true }>('POST', '/api/auth/totp/disable', { password }),
|
||||
totpNewCodes: (password: string) =>
|
||||
api<{ recoveryCodes: string[] }>('POST', '/api/auth/totp/recovery-codes', { password }),
|
||||
logout: () => api<{ ok: true }>('POST', '/api/auth/logout'),
|
||||
logout: () => api<{ ok: true; redirect?: string }>('POST', '/api/auth/logout'),
|
||||
methods: () => api<AuthMethods>('GET', '/api/auth/methods'),
|
||||
/** 12 §3.3: disconnect my OpenID account. */
|
||||
disconnectOidc: () => api<{ ok: true }>('DELETE', '/api/me/identities/oidc'),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
list: () => api<ListResponse<UserDto>>('GET', '/api/users').then((r) => r.items),
|
||||
/** temporaryPassword is null in OpenID-only mode (12 §3.3). */
|
||||
create: (u: UserCreate) =>
|
||||
api<{ user: UserDto; temporaryPassword: string }>('POST', '/api/users', u),
|
||||
api<{ user: UserDto; temporaryPassword: string | null }>('POST', '/api/users', u),
|
||||
update: (id: string, u: UserUpdate) => api<UserDto>('PATCH', `/api/users/${id}`, u),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}`),
|
||||
resetPassword: (id: string) =>
|
||||
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
|
||||
resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}),
|
||||
disconnectOidc: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}/identities/oidc`),
|
||||
};
|
||||
|
||||
export const extensionsApi = {
|
||||
@@ -237,8 +245,15 @@ export const settingsApi = {
|
||||
'/api/settings/emergency/test',
|
||||
),
|
||||
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
|
||||
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
|
||||
};
|
||||
|
||||
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
|
||||
export async function signOut(): Promise<void> {
|
||||
const r = await authApi.logout().catch(() => null);
|
||||
if (r?.redirect) window.location.assign(r.redirect);
|
||||
}
|
||||
|
||||
export const aboutApi = { get: () => api<AboutDto>('GET', '/api/about') };
|
||||
|
||||
export const aiApi = {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My sign-in ways (12 §5): the password, and connecting or disconnecting an OpenID account.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||
import { authApi } from '../api/client.ts';
|
||||
import { useMe } from '../auth/RequireAuth.tsx';
|
||||
import { Alert, Button } from './ui.tsx';
|
||||
|
||||
/** What `?sso_error=` means (12 §3.2), for the login page and here. */
|
||||
export function ssoErrorText(code: string, label: string): string {
|
||||
return (
|
||||
{
|
||||
expired: 'Sign-in took too long. Try again.',
|
||||
denied: 'Sign-in was cancelled.',
|
||||
not_allowed: `Your ${label} account isn't allowed to use the phone system. Ask your admin.`,
|
||||
not_connected: `This ${label} account isn't connected to a phone system account. Sign in with your password and connect it in your settings, or ask your admin.`,
|
||||
taken: `This ${label} account is already connected to another user.`,
|
||||
disabled: 'Your account is disabled.',
|
||||
too_many: 'Too many failed sign-ins. Wait 15 minutes and try again.',
|
||||
}[code] ?? `${label} sign-in didn't work. Your admin can see why in the log.`
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuthMethods = () =>
|
||||
useQuery({ queryKey: ['auth-methods'], queryFn: authApi.methods, staleTime: 60_000 });
|
||||
|
||||
export function SignInCard() {
|
||||
const me = useMe();
|
||||
const methods = useAuthMethods();
|
||||
const qc = useQueryClient();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const disconnect = useMutation({
|
||||
mutationFn: authApi.disconnectOidc,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
|
||||
});
|
||||
const ways = me.data?.signIn;
|
||||
const oidc = methods.data?.oidc;
|
||||
if (!ways) return null;
|
||||
const error = params.get('sso_error');
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">Sign-in</h2>
|
||||
{params.get('sso') === 'connected' && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Connected. You can now sign in with {oidc?.label ?? 'OpenID'}.
|
||||
</p>
|
||||
)}
|
||||
{error && <Alert>{ssoErrorText(error, oidc?.label ?? 'OpenID')}</Alert>}
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<h3 className="font-medium">Password</h3>
|
||||
{ways.password ? (
|
||||
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-zinc-500">
|
||||
You have no password here. An admin can give you one with Reset password.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{oidc && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<h3 className="font-medium">{oidc.label}</h3>
|
||||
{ways.oidc ? (
|
||||
<>
|
||||
<p>
|
||||
Connected{ways.oidc.email ? ` as ${ways.oidc.email}` : ''}. You can sign in with{' '}
|
||||
{oidc.label}
|
||||
{ways.password ? ' or your password' : ''}.
|
||||
</p>
|
||||
<div>
|
||||
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||
Disconnect {oidc.label}
|
||||
</Button>
|
||||
</div>
|
||||
{disconnect.error && <Alert>{disconnect.error.message}</Alert>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-zinc-500">
|
||||
Connect your {oidc.label} account to sign in with it as well.
|
||||
</p>
|
||||
<div>
|
||||
{/* A full page load: the browser goes to the provider and comes back. */}
|
||||
<a
|
||||
className="inline-block rounded-md border border-zinc-300 px-3 py-1.5 font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
href={`/api/auth/oidc/start?connect=1&next=${encodeURIComponent(loc.pathname)}`}
|
||||
>
|
||||
Connect {oidc.label}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { APP_NAME, LoginFields } from '@selfphoneng/shared';
|
||||
import { ApiError, authApi } from '../api/client.ts';
|
||||
import { homeFor } from '../auth/RequireAuth.tsx';
|
||||
import { ssoErrorText, useAuthMethods } from '../components/SignInCard.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
|
||||
|
||||
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
@@ -34,13 +35,19 @@ export function LoginPage() {
|
||||
const [code, setCode] = useState('');
|
||||
const form = useForm({ resolver: zodResolver(LoginFields.omit({ totp: true })) });
|
||||
const { errors, isSubmitting } = form.formState;
|
||||
// 12 §1.1: the OpenID button, the password form, or both. OpenID only: the form is for admins.
|
||||
const methods = useAuthMethods();
|
||||
const oidc = methods.data?.oidc;
|
||||
const [adminForm, setAdminForm] = useState(false);
|
||||
const showForm = methods.data?.mode !== 'oidc' || adminForm;
|
||||
const ssoError = params.get('sso_error');
|
||||
const next = params.get('next');
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ login, password }) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const user = await authApi.login(login, password, needCode ? code : undefined);
|
||||
qc.setQueryData(['me'], user);
|
||||
const next = params.get('next');
|
||||
// Only same-site paths: never redirect to another origin.
|
||||
nav(next?.startsWith('/') && !next.startsWith('//') ? next : homeFor(user), {
|
||||
replace: true,
|
||||
@@ -56,7 +63,41 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<AuthCard title="Sign in">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{ssoError && (
|
||||
<div className="mb-4">
|
||||
<Alert>{ssoErrorText(ssoError, oidc?.label ?? 'OpenID')}</Alert>
|
||||
</div>
|
||||
)}
|
||||
{oidc && (
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
{/* A full page load: the browser goes to the provider and comes back signed in. */}
|
||||
<a
|
||||
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`}
|
||||
className="rounded-md bg-sky-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-sky-800"
|
||||
>
|
||||
Sign in with {oidc.label}
|
||||
</a>
|
||||
{showForm && (
|
||||
<p className="text-center text-xs text-zinc-500" aria-hidden>
|
||||
or with your password
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showForm && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-zinc-500 hover:underline"
|
||||
onClick={() => setAdminForm(true)}
|
||||
>
|
||||
Admin sign-in
|
||||
</button>
|
||||
)}
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className={`flex flex-col gap-4 ${showForm ? '' : 'hidden'}`}
|
||||
noValidate
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Field label="Email" error={errors.login?.message}>
|
||||
{(a) => (
|
||||
|
||||
@@ -536,7 +536,7 @@ function UsersStep({ next }: { next: () => void }) {
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
number: number ?? '–',
|
||||
password: temporaryPassword,
|
||||
password: temporaryPassword ?? '(signs in with OpenID)',
|
||||
});
|
||||
setCreated([...out]);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My account (admins): password, and two-step sign-in (TOTP, 08 §2.4) with recovery codes.
|
||||
// My account (admins): password, OpenID (12 §5), and two-step sign-in (TOTP, 08 §2.4) with recovery codes.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { authApi } from '../../api/client.ts';
|
||||
import { useMe } from '../../auth/RequireAuth.tsx';
|
||||
import { SignInCard } from '../../components/SignInCard.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export function AccountPage() {
|
||||
const me = useMe();
|
||||
return (
|
||||
<section className="flex max-w-xl flex-col gap-6">
|
||||
<h1 className="text-xl font-semibold">My account</h1>
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Password</h2>
|
||||
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
</section>
|
||||
<TwoStep />
|
||||
<SignInCard />
|
||||
{/* Two-step sign-in guards the password, so it needs one (12 §1). */}
|
||||
{me.data?.signIn.password && <TwoStep />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
|
||||
import type { AuthUser } from '@selfphoneng/shared';
|
||||
import { authApi, systemApi } from '../../api/client.ts';
|
||||
import { signOut, systemApi } from '../../api/client.ts';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Brand, Button } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
@@ -43,7 +43,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
const setup = useQuery({ queryKey: ['setup'], queryFn: systemApi.setupStatus });
|
||||
|
||||
async function logout() {
|
||||
await authApi.logout().catch(() => undefined);
|
||||
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
|
||||
qc.clear();
|
||||
nav('/login', { replace: true });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
WEBHOOK_EVENTS,
|
||||
formatNumber,
|
||||
type AiTestResult,
|
||||
type AuthMode,
|
||||
type OidcInput,
|
||||
type SettingsDto,
|
||||
type WebhookDto,
|
||||
type WebhookEvent,
|
||||
@@ -38,6 +40,7 @@ const TABS = [
|
||||
{ id: 'tokens', label: 'API tokens' },
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'contacts', label: 'Contacts' },
|
||||
{ id: 'sign-in', label: 'Sign-in' },
|
||||
] as const;
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -74,6 +77,7 @@ export function SettingsPage() {
|
||||
{tab === 'tokens' && <TokensTab />}
|
||||
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
|
||||
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
|
||||
{settings.data && tab === 'sign-in' && <SignInTab s={settings.data} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1361,3 +1365,208 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = (s: string) =>
|
||||
s
|
||||
.split(/[\n,]/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** Sign-in (spec 12 §5): the mode, and the OpenID Connect provider. */
|
||||
function SignInTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const o = s['auth.oidc'];
|
||||
const [mode, setMode] = useState<AuthMode>(s['auth.mode']);
|
||||
const [v, setV] = useState({
|
||||
label: o?.label ?? 'Keycloak',
|
||||
issuer: o?.issuer ?? '',
|
||||
clientId: o?.clientId ?? 'selfphoneng',
|
||||
clientSecret: '',
|
||||
scopes: o?.scopes ?? 'openid email profile',
|
||||
groupsClaim: o?.groupsClaim ?? 'groups',
|
||||
adminGroups: o?.adminGroups.join('\n') ?? '',
|
||||
allowedGroups: o?.allowedGroups.join('\n') ?? '',
|
||||
trustEmail: o?.trustEmail ?? false,
|
||||
});
|
||||
const input = (): OidcInput => ({
|
||||
...v,
|
||||
clientSecret: v.clientSecret || undefined,
|
||||
adminGroups: lines(v.adminGroups),
|
||||
allowedGroups: lines(v.allowedGroups),
|
||||
});
|
||||
const test = useMutation({ mutationFn: () => settingsApi.testOidc(input()) });
|
||||
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
|
||||
setV({ ...v, [k]: e.target.value });
|
||||
const host = window.location.host;
|
||||
const uris = [
|
||||
['Redirect URI', `https://${host}/api/auth/oidc/callback`],
|
||||
['Post-logout redirect URI', `https://${host}/login`],
|
||||
] as const;
|
||||
const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
className={inputClass}
|
||||
placeholder={placeholder}
|
||||
value={v[k] as string}
|
||||
onChange={set(k)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
const area = (k: 'adminGroups' | 'allowedGroups', label: string, hint: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
{(a) => <textarea {...a} rows={2} className={inputClass} value={v[k]} onChange={set(k)} />}
|
||||
</Field>
|
||||
);
|
||||
const modes = [
|
||||
['password', 'Passwords', 'Email and password, as set up here.'],
|
||||
[
|
||||
'mixed',
|
||||
'Passwords or OpenID',
|
||||
`Both. People connect their ${v.label} account in their settings. Disabling someone in ${v.label} ends their ${v.label} sign-ins, but their password keeps working until you disable them on Users.`,
|
||||
],
|
||||
[
|
||||
'oidc',
|
||||
'OpenID only',
|
||||
`Only ${v.label} decides who gets in. Admins can still sign in with a password (Admin sign-in on the login page), for when ${v.label} is down.`,
|
||||
],
|
||||
] as const;
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({ 'auth.oidc': v.issuer.trim() ? input() : null, 'auth.mode': mode });
|
||||
setV({ ...v, clientSecret: '' });
|
||||
}}
|
||||
>
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">How people sign in</legend>
|
||||
{modes.map(([id, title, about]) => (
|
||||
<label key={id} className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-mode"
|
||||
checked={mode === id}
|
||||
onChange={() => setMode(id)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{title}</span>: {about}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">OpenID Connect</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Keycloak, Authentik, Authelia, Zitadel, Entra ID, Google and others. At the provider, make
|
||||
a confidential client using the standard (authorization code) flow, and add these:
|
||||
</p>
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
{uris.map(([label, uri]) => (
|
||||
<div key={label} className="flex flex-wrap items-center gap-2">
|
||||
<dt className="w-48 text-zinc-500">{label}</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<code className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{uri}</code>
|
||||
<Button onClick={() => void navigator.clipboard?.writeText(uri)}>Copy</Button>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{text('label', 'Button name', 'Shown as "Sign in with …"')}
|
||||
{text(
|
||||
'issuer',
|
||||
'Issuer',
|
||||
'Exactly as the provider writes it. Leave empty to turn OpenID Connect off.',
|
||||
'https://kc.example.com/realms/company',
|
||||
)}
|
||||
{text('clientId', 'Client ID')}
|
||||
<Field
|
||||
label="Client secret"
|
||||
hint={
|
||||
o?.hasClientSecret
|
||||
? 'Saved. Type a new one to replace it.'
|
||||
: 'Empty for a public client (PKCE only)'
|
||||
}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={o?.hasClientSecret ? '••••••••' : ''}
|
||||
className={inputClass}
|
||||
value={v.clientSecret}
|
||||
onChange={set('clientSecret')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{text(
|
||||
'scopes',
|
||||
'Scopes',
|
||||
'Add offline_access so people who keep the softphone open stay signed in past the provider’s session limit.',
|
||||
)}
|
||||
{text(
|
||||
'groupsClaim',
|
||||
'Groups claim',
|
||||
'Where the provider puts groups or roles: groups, roles, realm_access.roles, resource_access.<client>.roles …',
|
||||
)}
|
||||
{area(
|
||||
'adminGroups',
|
||||
'Admin groups',
|
||||
'One per line, exactly as sent (Keycloak with full group path: /pbx-admins). Members are admins, others users. Empty: you set roles on Users.',
|
||||
)}
|
||||
{area(
|
||||
'allowedGroups',
|
||||
'Allowed groups',
|
||||
'One per line. Empty: anyone with a connected account.',
|
||||
)}
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4"
|
||||
checked={v.trustEmail}
|
||||
onChange={(e) => setV({ ...v, trustEmail: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Trust email addresses without “email verified”. Turn on only if people can’t change
|
||||
their own email at {v.label} (a single company’s Keycloak with email editing off, a
|
||||
single-tenant Entra ID). Only used to match people who have no password here.
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending || !v.issuer.trim()}>
|
||||
Test
|
||||
</Button>
|
||||
{test.data && (
|
||||
<span
|
||||
role="status"
|
||||
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
|
||||
>
|
||||
{test.data.ok
|
||||
? `Works: ${test.data.issuer}. Signing: ${test.data.algorithms.join(', ') || '?'}. ${test.data.refreshTokens ? 'Refresh tokens' : 'No refresh tokens'}, ${test.data.endSession ? 'signs out there too' : 'no sign-out endpoint'}.`
|
||||
: test.data.error}
|
||||
</span>
|
||||
)}
|
||||
{test.error && <Alert>{test.error.message}</Alert>}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Then connect your own account in My account, and sign in from a private window.
|
||||
</p>
|
||||
</fieldset>
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{save.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { Dialog } from '../../components/Dialog.tsx';
|
||||
import { MohOptions } from '../../components/MohOptions.tsx';
|
||||
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { useAuthMethods } from '../../components/SignInCard.tsx';
|
||||
|
||||
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
|
||||
|
||||
@@ -164,7 +165,8 @@ function GeneralTab({
|
||||
role: v.role,
|
||||
});
|
||||
userId = r.user.id;
|
||||
created = { email: r.user.email, password: r.temporaryPassword };
|
||||
// OpenID-only mode makes no password: they sign in with the provider (12 §3.3).
|
||||
if (r.temporaryPassword) created = { email: r.user.email, password: r.temporaryPassword };
|
||||
}
|
||||
// The user's extension: create it, or renumber it.
|
||||
if (v.number && !ext)
|
||||
@@ -238,6 +240,7 @@ function GeneralTab({
|
||||
Creates a new temporary password and signs them out everywhere.
|
||||
</p>
|
||||
{user.twoStep && <ResetTwoStep user={user} />}
|
||||
{user.signIn.oidc && <ConnectedOidc user={user} />}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
@@ -584,6 +587,33 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
|
||||
);
|
||||
}
|
||||
|
||||
/** Their connected OpenID account (12 §5): the groups it sent, and Disconnect. */
|
||||
function ConnectedOidc({ user }: { user: UserDto }) {
|
||||
const qc = useQueryClient();
|
||||
const label = useAuthMethods().data?.oidc?.label ?? 'OpenID';
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => usersApi.disconnectOidc(user.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
const oidc = user.signIn.oidc!;
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-1 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
|
||||
</p>
|
||||
<div>
|
||||
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||
Disconnect {label}
|
||||
</Button>
|
||||
</div>
|
||||
{disconnect.error && <Alert>{disconnect.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** For a user who lost their phone and recovery codes (08 §2.4). */
|
||||
function ResetTwoStep({ user }: { user: UserDto }) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { UserDrawer } from './UserDrawer.tsx';
|
||||
import { StatusDot } from '../../components/StatusDot.tsx';
|
||||
import { useLive } from '../../live/store.ts';
|
||||
import { useAuthMethods } from '../../components/SignInCard.tsx';
|
||||
|
||||
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
|
||||
|
||||
@@ -129,6 +130,7 @@ function UsersTable({
|
||||
onDelete: (u: UserDto) => void;
|
||||
}) {
|
||||
const live = useLive((s) => s.extensions);
|
||||
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
|
||||
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
|
||||
const ext = (u: UserDto) =>
|
||||
u.extension ? (
|
||||
@@ -153,7 +155,7 @@ function UsersTable({
|
||||
);
|
||||
// 12 §5: every way this user can sign in.
|
||||
const ways = (u: UserDto) =>
|
||||
[u.signIn.password && 'Password', u.signIn.oidc && 'OpenID', u.signIn.ldap && 'Directory']
|
||||
[u.signIn.password && 'Password', u.signIn.oidc && oidcLabel, u.signIn.ldap && 'Directory']
|
||||
.filter(Boolean)
|
||||
.join(', ') || '–';
|
||||
const actions = (u: UserDto) => (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
|
||||
import type { AuthUser } from '@selfphoneng/shared';
|
||||
import { authApi, meApi } from '../../api/client.ts';
|
||||
import { meApi, signOut } from '../../api/client.ts';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Alert, Brand, Button } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
@@ -57,7 +57,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
await authApi.logout().catch(() => undefined);
|
||||
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
|
||||
qc.clear();
|
||||
nav('/login', { replace: true });
|
||||
}}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
|
||||
import { SignInCard } from '../../components/SignInCard.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
/** "102" stays as is; outside numbers get their usual format. */
|
||||
@@ -70,12 +70,7 @@ function SettingsForms({ me }: { me: MeDto }) {
|
||||
</h2>
|
||||
<PhoneAppSetup load={meApi.softphoneQr} />
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">Password</h2>
|
||||
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
</section>
|
||||
<SignInCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -629,6 +629,7 @@ with a zod schema per key and a default. Missing rows return the default.
|
||||
| `webhooks.allowPrivate` | boolean | false | allow webhook URLs on private/loopback IPs |
|
||||
| `setup.completed` | boolean | false | first-run wizard done |
|
||||
| `auth.mode` | `'password'\|'mixed'\|'oidc'` | `'password'` | sign-in ways offered (12 §1.1) |
|
||||
| `auth.oidc` | object \| null | null | the OpenID Connect provider, client secret encrypted (12 §3.1) |
|
||||
| `sms.webhookToken` | string | random 32 chars, made on first read | secret part of the voip.ms SMS callback URL |
|
||||
| `sip.restrictPhonesToLocalNets` | boolean | false | only accept phone registrations from `network.localNets` (+ `sip.extraAllowedNets`) |
|
||||
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
|
||||
|
||||
+26
-1
@@ -450,7 +450,7 @@ For every active user with a directory account, one connection per run:
|
||||
| Task | What | Done when |
|
||||
|---|---|---|
|
||||
| S1 | Decision 0004; migration (§2); `auth.mode` (§1.1) with only `password` selectable; break glass; `GET /api/auth/methods`; the `login` field; Users "Signs in with" | `pnpm lint && typecheck && test` pass; sign-in works as before. Done, see §8.1 |
|
||||
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider |
|
||||
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider. Done, see §8.2 |
|
||||
| S3 | OIDC session check (§3.5); Keycloak live test; admin guide page `docs/admin-guide/sign-in.md` with the §3.6 table | the owner connects their Keycloak account, signs in with it or their password, a Keycloak group makes them admin, signing out also signs out of Keycloak, and disabling them in Keycloak ends their Keycloak session here within 20 min |
|
||||
| S4 | LDAP: settings + Test, presets, sign-in flow (§4.2), §4.3, unit + lldap live tests | sign-in by uid and email against lldap over LDAPS; admin by group; no user made for a stranger |
|
||||
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works |
|
||||
@@ -464,6 +464,31 @@ For every active user with a directory account, one connection per run:
|
||||
gives it the provider's label).
|
||||
- `apps/api/test/sign-in.test.ts`.
|
||||
|
||||
### 8.2 As built: S2
|
||||
|
||||
- `sso/oidc.ts` (discovery cached per settings value, client auth, start/finish, end session,
|
||||
Test), `sso/claims.ts` (pure), `routes/oidc.ts` (start, callback, both Disconnect routes).
|
||||
- **ID token signatures are checked** (`enableNonRepudiationChecks`): openid-client by default
|
||||
trusts TLS to the token endpoint instead (OIDC Core §3.1.3.7 allows it), and the fake provider's
|
||||
"bad signature" test caught that. Asymmetric algorithms only: a provider signing ID tokens with
|
||||
HS256 (the client secret) is refused. Keycloak, Authentik, Authelia and the others default to
|
||||
RS256 or ES256.
|
||||
- `client_secret_basic` form-encodes the id and secret before base64 (RFC 6749 §2.3.1: `-` →
|
||||
`%2D`); providers decode it (Keycloak does). If one doesn't, a secret with symbols fails with
|
||||
`invalid_client`: `// TODO(spec)` fall back to `client_secret_post` then, if it's ever seen.
|
||||
- Callback failures count against the IP only (`isLoginBlocked(ip, null)`); one more code,
|
||||
`too_many`.
|
||||
- `must_change_password` doesn't block OpenID sessions (guards and the live socket both use
|
||||
`mustChange()`): it's about the password, which they didn't use.
|
||||
- `signIn.oidc` carries the `groups` too, shown in the Users drawer to copy into Settings.
|
||||
- The callback's own log line has no query; there is no automated check that Fastify's request
|
||||
log skips it (`logLevel: 'silent'`, as the caller-name route).
|
||||
- Web: Settings → Sign-in, the login page button and `sso_error` messages, **Admin sign-in** in
|
||||
`oidc` mode, a Sign-in card on My account and My settings (connect, disconnect), the Users
|
||||
drawer's connected account, sign out following `redirect`.
|
||||
- `apps/api/test/oidc.test.ts` (every case with RS256 and with ES256), `fake-oidc.ts`.
|
||||
- Not yet seen in a browser against a real provider: that's S3 with the owner's Keycloak.
|
||||
|
||||
## 9. Decided
|
||||
|
||||
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
|
||||
|
||||
@@ -42,7 +42,8 @@ export type AuthUser = {
|
||||
/** The ways a user can sign in (12 §2): their password, a connected OpenID account, the directory. */
|
||||
export type SignInWays = {
|
||||
password: boolean;
|
||||
oidc: { email: string | null } | null;
|
||||
/** `groups`: what the provider sent at the last sign-in (12 §3.4), to copy into Settings. */
|
||||
oidc: { email: string | null; groups: string[] } | null;
|
||||
ldap: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,21 @@ export const SettingsSchemas = {
|
||||
.default(null),
|
||||
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
|
||||
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
|
||||
/** OpenID Connect (12 §3.1). The client secret is encrypted; null = a public client. */
|
||||
'auth.oidc': z
|
||||
.object({
|
||||
label: z.string(),
|
||||
issuer: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecretEnc: z.string().nullable(),
|
||||
scopes: z.string(),
|
||||
groupsClaim: z.string(),
|
||||
adminGroups: z.array(z.string()),
|
||||
allowedGroups: z.array(z.string()),
|
||||
trustEmail: z.boolean(),
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
// Not in 02 §3's table but used by the generator (03 §3).
|
||||
'system.transportHash': z.string().nullable().default(null),
|
||||
};
|
||||
@@ -150,12 +165,56 @@ export const ContrackInputSchema = z.object({
|
||||
});
|
||||
export type ContrackInput = z.input<typeof ContrackInputSchema>;
|
||||
|
||||
const groupList = z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(50)
|
||||
.transform((l) => [...new Set(l)]);
|
||||
|
||||
/** PATCH /api/settings `auth.oidc` and its Test: the client secret is write-only (empty keeps it). */
|
||||
export const OidcInputSchema = z.object({
|
||||
label: z.string().trim().min(1, 'Required').max(40),
|
||||
// Exactly as the provider writes it: the issuer is compared character for character
|
||||
// (Authentik's ends in "/").
|
||||
issuer: z.url({ protocol: /^https?$/ }).trim(),
|
||||
clientId: z.string().trim().min(1, 'Required').max(200),
|
||||
clientSecret: z.string().max(500).optional(),
|
||||
clearClientSecret: z.boolean().optional(),
|
||||
scopes: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.refine((s) => s.split(/\s+/).includes('openid'), 'Must include openid'),
|
||||
groupsClaim: z.string().trim().min(1).max(200),
|
||||
adminGroups: groupList,
|
||||
allowedGroups: groupList,
|
||||
trustEmail: z.boolean(),
|
||||
});
|
||||
export type OidcInput = z.input<typeof OidcInputSchema>;
|
||||
|
||||
/** POST /api/settings/auth/oidc/test. */
|
||||
export type OidcTestResult =
|
||||
| {
|
||||
ok: true;
|
||||
issuer: string;
|
||||
algorithms: string[];
|
||||
pkce: boolean;
|
||||
refreshTokens: boolean;
|
||||
endSession: boolean;
|
||||
redirectUri: string;
|
||||
postLogoutRedirectUri: string;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
|
||||
export type SettingsDto = {
|
||||
[K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken' | 'contacts.contrack'>]: SettingValue<K>;
|
||||
[
|
||||
K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc'>
|
||||
]: SettingValue<K>;
|
||||
} & {
|
||||
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
|
||||
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
|
||||
'auth.oidc':
|
||||
(Omit<OidcInput, 'clientSecret' | 'clearClientSecret'> & { hasClientSecret: boolean }) | null;
|
||||
};
|
||||
|
||||
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */
|
||||
|
||||
Generated
+21
@@ -65,6 +65,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: 10.0.11
|
||||
version: 10.0.11
|
||||
openid-client:
|
||||
specifier: ^6.8.8
|
||||
version: 6.8.8
|
||||
pg:
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
@@ -1935,6 +1938,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.13:
|
||||
resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2171,6 +2177,9 @@ packages:
|
||||
resolution: {integrity: sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
oauth4webapi@3.8.8:
|
||||
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||
|
||||
obug@3.0.0:
|
||||
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -2182,6 +2191,9 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
openid-client@6.8.8:
|
||||
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4097,6 +4109,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.13: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
@@ -4267,6 +4281,8 @@ snapshots:
|
||||
|
||||
nodemailer@10.0.11: {}
|
||||
|
||||
oauth4webapi@3.8.8: {}
|
||||
|
||||
obug@3.0.0: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
@@ -4275,6 +4291,11 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
openid-client@6.8.8:
|
||||
dependencies:
|
||||
jose: 6.2.13
|
||||
oauth4webapi: 3.8.8
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
Reference in new issue
Block a user