refactor: correctly delete orphan sessions after idp deletion
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
This commit is contained in:
parent
c1e8036323
commit
c2ed9e3693
13 changed files with 679 additions and 70 deletions
|
|
@ -24,8 +24,8 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
|||
</FormControl>
|
||||
<FormDescription>
|
||||
Advanced: enter one restic flag per line (e.g.{" "}
|
||||
<code className="bg-muted px-1 rounded">--iexclude-larger-than 500M</code>). These are appended to the
|
||||
backup command as-is.
|
||||
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list is
|
||||
accepted.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { authService } from "../auth.service";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
function randomSlug(prefix: string) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(email: string) {
|
||||
const id = randomId();
|
||||
|
||||
await db.insert(usersTable).values({
|
||||
id,
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
username: randomSlug("user"),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createOrganization(name: string) {
|
||||
const id = randomId();
|
||||
|
||||
await db.insert(organization).values({
|
||||
id,
|
||||
name,
|
||||
slug: randomSlug("org"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createMembership({
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
}) {
|
||||
const id = randomId();
|
||||
await db.insert(member).values({ id, userId, organizationId, role, createdAt: new Date() });
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createSession({
|
||||
userId,
|
||||
activeOrganizationId,
|
||||
}: {
|
||||
userId: string;
|
||||
activeOrganizationId: string | null;
|
||||
}) {
|
||||
await db.insert(sessionsTable).values({
|
||||
id: randomId(),
|
||||
userId,
|
||||
token: randomSlug("token"),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
activeOrganizationId,
|
||||
});
|
||||
}
|
||||
|
||||
async function createAccount({ userId, providerId }: { userId: string; providerId: string }) {
|
||||
const id = randomId();
|
||||
|
||||
await db.insert(account).values({
|
||||
id,
|
||||
accountId: randomSlug("account"),
|
||||
providerId,
|
||||
userId,
|
||||
password: providerId === "credential" ? "password-hash" : null,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("authService account and membership management", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(account);
|
||||
await db.delete(member);
|
||||
await db.delete(sessionsTable);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("deleteUserAccount removes the selected account and keeps existing sessions", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const credentialAccountId = await createAccount({
|
||||
userId,
|
||||
providerId: "credential",
|
||||
});
|
||||
await createAccount({ userId, providerId: "oidc-acme" });
|
||||
await createSession({ userId, activeOrganizationId: null });
|
||||
|
||||
const result = await authService.deleteUserAccount(userId, credentialAccountId);
|
||||
|
||||
expect(result).toEqual({ lastAccount: false, notFound: false });
|
||||
|
||||
const deletedAccount = await db.query.account.findFirst({
|
||||
where: { id: credentialAccountId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingSessions = await db.query.sessionsTable.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(deletedAccount).toBeUndefined();
|
||||
expect(remainingSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("deleteUserAccount returns notFound when the account does not belong to the user", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
await createAccount({ userId, providerId: "credential" });
|
||||
await createAccount({ userId, providerId: "oidc-acme" });
|
||||
|
||||
const result = await authService.deleteUserAccount(userId, randomId());
|
||||
|
||||
expect(result).toEqual({ lastAccount: false, notFound: true });
|
||||
});
|
||||
|
||||
test("deleteUserAccount refuses to delete when it is the user's last account", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const onlyAccountId = await createAccount({
|
||||
userId,
|
||||
providerId: "credential",
|
||||
});
|
||||
|
||||
const result = await authService.deleteUserAccount(userId, onlyAccountId);
|
||||
|
||||
expect(result).toEqual({ lastAccount: true, notFound: false });
|
||||
|
||||
const existingAccount = await db.query.account.findFirst({
|
||||
where: { id: onlyAccountId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(existingAccount).toEqual({ id: onlyAccountId });
|
||||
});
|
||||
|
||||
test("removeOrgMember rehomes active sessions to another organization membership", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const removedOrgId = await createOrganization("Removed Org");
|
||||
const fallbackOrgId = await createOrganization("Fallback Org");
|
||||
const membershipId = await createMembership({
|
||||
userId,
|
||||
organizationId: removedOrgId,
|
||||
role: "member",
|
||||
});
|
||||
await createMembership({
|
||||
userId,
|
||||
organizationId: fallbackOrgId,
|
||||
role: "member",
|
||||
});
|
||||
await createSession({ userId, activeOrganizationId: removedOrgId });
|
||||
|
||||
const result = await authService.removeOrgMember(membershipId, removedOrgId);
|
||||
|
||||
expect(result).toEqual({ found: true, isOwner: false });
|
||||
|
||||
const session = await db.query.sessionsTable.findFirst({
|
||||
where: { userId },
|
||||
columns: { activeOrganizationId: true },
|
||||
});
|
||||
const removedMembership = await db.query.member.findFirst({
|
||||
where: { id: membershipId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(session?.activeOrganizationId).toBe(fallbackOrgId);
|
||||
expect(removedMembership).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removeOrgMember revokes sessions when the removed user has no fallback organization", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const removedOrgId = await createOrganization("Removed Org");
|
||||
const membershipId = await createMembership({
|
||||
userId,
|
||||
organizationId: removedOrgId,
|
||||
role: "member",
|
||||
});
|
||||
await createSession({ userId, activeOrganizationId: removedOrgId });
|
||||
|
||||
const result = await authService.removeOrgMember(membershipId, removedOrgId);
|
||||
|
||||
expect(result).toEqual({ found: true, isOwner: false });
|
||||
|
||||
const remainingSessions = await db.query.sessionsTable.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const removedMembership = await db.query.member.findFirst({
|
||||
where: { id: membershipId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingSessions).toHaveLength(0);
|
||||
expect(removedMembership).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removeOrgMember returns isOwner=true and does not remove owner membership", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const orgId = await createOrganization("Owner Org");
|
||||
const membershipId = await createMembership({
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
const result = await authService.removeOrgMember(membershipId, orgId);
|
||||
|
||||
expect(result).toEqual({ found: true, isOwner: true });
|
||||
|
||||
const existingMembership = await db.query.member.findFirst({
|
||||
where: { id: membershipId },
|
||||
columns: { id: true, role: true },
|
||||
});
|
||||
|
||||
expect(existingMembership).toEqual({ id: membershipId, role: "owner" });
|
||||
});
|
||||
|
||||
test("removeOrgMember returns found=false for missing membership and leaves members/sessions unchanged", async () => {
|
||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
||||
const orgId = await createOrganization("Existing Org");
|
||||
const otherOrgId = await createOrganization("Other Org");
|
||||
const membershipId = await createMembership({
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
role: "member",
|
||||
});
|
||||
await createMembership({
|
||||
userId,
|
||||
organizationId: otherOrgId,
|
||||
role: "member",
|
||||
});
|
||||
await createSession({ userId, activeOrganizationId: orgId });
|
||||
|
||||
const membersBefore = await db.query.member.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const sessionsBefore = await db.query.sessionsTable.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true, activeOrganizationId: true },
|
||||
});
|
||||
|
||||
const result = await authService.removeOrgMember(randomId(), orgId);
|
||||
|
||||
expect(result).toEqual({ found: false, isOwner: false });
|
||||
|
||||
const membersAfter = await db.query.member.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const sessionsAfter = await db.query.sessionsTable.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true, activeOrganizationId: true },
|
||||
});
|
||||
const membershipStillExists = await db.query.member.findFirst({
|
||||
where: { id: membershipId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(membersAfter).toEqual(membersBefore);
|
||||
expect(sessionsAfter).toEqual(sessionsBefore);
|
||||
expect(membershipStillExists).toEqual({ id: membershipId });
|
||||
});
|
||||
});
|
||||
|
|
@ -152,6 +152,42 @@ describe("auth controller security", () => {
|
|||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
}
|
||||
|
||||
test("global admins can delete an account for a user outside their active organization", async () => {
|
||||
const { headers } = await createTestSessionWithGlobalAdmin();
|
||||
const target = await createTestSession();
|
||||
|
||||
const retainedAccountId = Bun.randomUUIDv7();
|
||||
await db.insert(account).values({
|
||||
id: retainedAccountId,
|
||||
accountId: `credential-${retainedAccountId}`,
|
||||
providerId: "credential",
|
||||
userId: target.user.id,
|
||||
password: "password-hash",
|
||||
});
|
||||
|
||||
const removableAccountId = Bun.randomUUIDv7();
|
||||
await db.insert(account).values({
|
||||
id: removableAccountId,
|
||||
accountId: `oidc-${removableAccountId}`,
|
||||
providerId: "oidc-acme",
|
||||
userId: target.user.id,
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/v1/auth/admin-users/${target.user.id}/accounts/${removableAccountId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const deletedAccount = await db.query.account.findFirst({
|
||||
where: { id: removableAccountId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(deletedAccount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid session handling", () => {
|
||||
|
|
|
|||
|
|
@ -49,18 +49,16 @@ export const authController = new Hono()
|
|||
.delete("/admin-users/:userId/accounts/:accountId", requireAuth, requireAdmin, deleteUserAccountDto, async (c) => {
|
||||
const userId = c.req.param("userId");
|
||||
const accountId = c.req.param("accountId");
|
||||
const organizationId = c.get("organizationId");
|
||||
|
||||
const result = await authService.deleteUserAccount(userId, accountId, organizationId);
|
||||
|
||||
if (result.forbidden) {
|
||||
return c.json({ message: "User is not a member of this organization" }, 403);
|
||||
}
|
||||
const result = await authService.deleteUserAccount(userId, accountId);
|
||||
|
||||
if (result.lastAccount) {
|
||||
return c.json({ message: "Cannot delete the last account of a user" }, 409);
|
||||
}
|
||||
|
||||
if (result.notFound) {
|
||||
return c.json({ message: "Account not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ export const deleteUserAccountDto = describeRoute({
|
|||
200: {
|
||||
description: "Account deleted successfully",
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
404: {
|
||||
description: "Account not found",
|
||||
},
|
||||
409: {
|
||||
description: "Cannot delete the last account",
|
||||
|
|
|
|||
|
|
@ -97,7 +97,10 @@ export class AuthService {
|
|||
|
||||
if (affectedUserIds.length > 0) {
|
||||
const memberships = await tx
|
||||
.select({ userId: member.userId, organizationId: member.organizationId })
|
||||
.select({
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
})
|
||||
.from(member)
|
||||
.where(inArray(member.userId, affectedUserIds));
|
||||
|
||||
|
|
@ -127,16 +130,6 @@ export class AuthService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of an organization
|
||||
*/
|
||||
async getUserMembership(userId: string, organizationId: string) {
|
||||
return db.query.member.findFirst({
|
||||
where: { AND: [{ userId }, { organizationId }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch accounts for a list of users, keyed by userId
|
||||
*/
|
||||
|
|
@ -153,7 +146,10 @@ export class AuthService {
|
|||
if (!grouped[row.userId]) {
|
||||
grouped[row.userId] = [];
|
||||
}
|
||||
grouped[row.userId].push({ id: row.id, providerId: row.providerId });
|
||||
grouped[row.userId].push({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
});
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
|
@ -220,7 +216,30 @@ export class AuthService {
|
|||
return { found: true, isOwner: true } as const;
|
||||
}
|
||||
|
||||
await db.delete(member).where(eq(member.id, memberId));
|
||||
await db.transaction(async (tx) => {
|
||||
const fallbackMembership = await tx.query.member.findFirst({
|
||||
where: {
|
||||
AND: [{ userId: targetMember.userId }, { organizationId: { ne: organizationId } }],
|
||||
},
|
||||
columns: { organizationId: true },
|
||||
});
|
||||
|
||||
await tx.delete(member).where(eq(member.id, memberId));
|
||||
|
||||
if (fallbackMembership?.organizationId) {
|
||||
await tx
|
||||
.update(sessionsTable)
|
||||
.set({
|
||||
activeOrganizationId: fallbackMembership.organizationId,
|
||||
})
|
||||
.where(
|
||||
and(eq(sessionsTable.userId, targetMember.userId), eq(sessionsTable.activeOrganizationId, organizationId)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, targetMember.userId));
|
||||
});
|
||||
|
||||
return { found: true, isOwner: false } as const;
|
||||
}
|
||||
|
|
@ -241,24 +260,32 @@ export class AuthService {
|
|||
/**
|
||||
* Delete a single account for a user, refusing if it is the last one
|
||||
*/
|
||||
async deleteUserAccount(userId: string, accountId: string, organizationId: string) {
|
||||
const membership = await this.getUserMembership(userId, organizationId);
|
||||
if (!membership) {
|
||||
return { lastAccount: false, forbidden: true };
|
||||
}
|
||||
|
||||
async deleteUserAccount(userId: string, accountId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [targetAccount] = await tx
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!targetAccount) {
|
||||
return { lastAccount: false, notFound: true };
|
||||
}
|
||||
|
||||
const userAccounts = await tx.query.account.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (userAccounts.length <= 1) {
|
||||
return { lastAccount: true, forbidden: false };
|
||||
return { lastAccount: true, notFound: false };
|
||||
}
|
||||
|
||||
await tx.delete(account).where(and(eq(account.id, accountId), eq(account.userId, userId)));
|
||||
return { lastAccount: false, forbidden: false };
|
||||
// Sessions cannot be tied to a specific account/provider with the current schema,
|
||||
// so keep active sessions intact when unlinking a single account.
|
||||
|
||||
return { lastAccount: false, notFound: false };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
app/server/modules/sso/__tests__/sso.registration.test.ts
Normal file
106
app/server/modules/sso/__tests__/sso.registration.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { createApp } from "~/server/app";
|
||||
import { config } from "~/server/core/config";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema";
|
||||
import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth";
|
||||
|
||||
const app = createApp();
|
||||
const ssoRegisterUrl = new URL("/api/auth/sso/register", config.baseUrl).toString();
|
||||
|
||||
function buildRegisterBody(organizationId: string, suffix: string) {
|
||||
return {
|
||||
providerId: `oidc-${suffix}-${Bun.randomUUIDv7()}`,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
organizationId,
|
||||
oidcConfig: {
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
skipDiscovery: true,
|
||||
discoveryEndpoint: "https://issuer.example.com/.well-known/openid-configuration",
|
||||
authorizationEndpoint: "https://issuer.example.com/oauth2/authorize",
|
||||
tokenEndpoint: "https://issuer.example.com/oauth2/token",
|
||||
jwksEndpoint: "https://issuer.example.com/.well-known/jwks.json",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SSO provider registration authorization", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(verification);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("allows organization owners to register providers for their active organization", async () => {
|
||||
const { headers, organizationId } = await createTestSession();
|
||||
|
||||
const response = await app.request(ssoRegisterUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildRegisterBody(organizationId, "owner")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("rejects org admins for registration when they are not owners", async () => {
|
||||
const { headers, organizationId } = await createTestSessionWithOrgAdmin();
|
||||
|
||||
const response = await app.request(ssoRegisterUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildRegisterBody(organizationId, "admin")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Only organization owners can register SSO providers");
|
||||
});
|
||||
|
||||
test("rejects users who are owners elsewhere but only members of the target organization", async () => {
|
||||
const { headers, user } = await createTestSession();
|
||||
const targetOrgId = Bun.randomUUIDv7();
|
||||
|
||||
await db.insert(organization).values({
|
||||
id: targetOrgId,
|
||||
name: "Target Org",
|
||||
slug: `target-org-${Date.now()}`,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(member).values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId: user.id,
|
||||
organizationId: targetOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const response = await app.request(ssoRegisterUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildRegisterBody(targetOrgId, "cross-org")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Only organization owners can register SSO providers");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { ssoService } from "../sso.service";
|
||||
|
||||
function randomId() {
|
||||
|
|
@ -37,10 +37,20 @@ async function createOrganization(name: string) {
|
|||
return id;
|
||||
}
|
||||
|
||||
async function createSession(userId: string) {
|
||||
await db.insert(sessionsTable).values({
|
||||
id: randomId(),
|
||||
userId,
|
||||
token: randomSlug("token"),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ssoService.deleteSsoProvider", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(sessionsTable);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
|
|
@ -119,6 +129,8 @@ describe("ssoService.deleteSsoProvider", () => {
|
|||
userId: accountUserB,
|
||||
},
|
||||
]);
|
||||
await createSession(accountUserA);
|
||||
await createSession(accountUserB);
|
||||
|
||||
const deleted = await ssoService.deleteSsoProvider(providerId, org);
|
||||
|
||||
|
|
@ -132,9 +144,14 @@ describe("ssoService.deleteSsoProvider", () => {
|
|||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingSessions = await db.query.sessionsTable.findMany({
|
||||
where: { userId: { in: [accountUserA, accountUserB] } },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingProvider).toBeUndefined();
|
||||
expect(remainingAccounts).toHaveLength(0);
|
||||
expect(remainingSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("deleting a reserved provider id never deletes credential accounts", async () => {
|
||||
|
|
|
|||
45
app/server/modules/sso/middlewares/authorize-registration.ts
Normal file
45
app/server/modules/sso/middlewares/authorize-registration.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { APIError } from "better-auth/api";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { db } from "~/server/db/db";
|
||||
|
||||
export const authorizeSsoRegistration = async (ctx: AuthMiddlewareContext) => {
|
||||
if (ctx.path !== "/sso/register") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
||||
if (!sessionToken) {
|
||||
throw new APIError("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
const session = await ctx.context.internalAdapter.findSession(sessionToken);
|
||||
if (!session || session.session.expiresAt < new Date()) {
|
||||
throw new APIError("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
ctx.context.session = session;
|
||||
|
||||
if (!ctx.body || typeof ctx.body !== "object") {
|
||||
throw new APIError("BAD_REQUEST", { message: "Missing SSO registration payload" });
|
||||
}
|
||||
|
||||
const organizationId = (ctx.body as Record<string, unknown>).organizationId;
|
||||
if (typeof organizationId !== "string" || organizationId.length === 0) {
|
||||
throw new APIError("BAD_REQUEST", { message: "organizationId is required to register an SSO provider" });
|
||||
}
|
||||
|
||||
const membership = await db.query.member.findFirst({
|
||||
where: {
|
||||
AND: [{ userId: session.user.id }, { organizationId }],
|
||||
},
|
||||
columns: { role: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new APIError("FORBIDDEN", { message: "You are not a member of this organization" });
|
||||
}
|
||||
|
||||
if (membership.role !== "owner") {
|
||||
throw new APIError("FORBIDDEN", { message: "Only organization owners can register SSO providers" });
|
||||
}
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import { authService } from "../auth/auth.service";
|
|||
import { ssoService } from "./sso.service";
|
||||
import { validateSsoProviderId } from "./middlewares/validate-provider-id";
|
||||
import { validateSsoCallbackUrls } from "./middlewares/validate-callback-urls";
|
||||
import { authorizeSsoRegistration } from "./middlewares/authorize-registration";
|
||||
import { requireSsoInvitation } from "./middlewares/require-invitation";
|
||||
import { resolveTrustedProvidersForRequest } from "./middlewares/trust-provider-for-linking";
|
||||
import { isSsoCallbackRequest, extractProviderIdFromContext, normalizeEmail } from "./utils/sso-context";
|
||||
|
|
@ -143,7 +144,7 @@ export const ssoIntegration = {
|
|||
},
|
||||
}),
|
||||
|
||||
beforeMiddlewares: [validateSsoProviderId, validateSsoCallbackUrls] as const,
|
||||
beforeMiddlewares: [validateSsoProviderId, validateSsoCallbackUrls, authorizeSsoRegistration] as const,
|
||||
|
||||
isSsoCallback: isSsoCallbackRequest,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import { ssoProvider, account, invitation, organization } from "~/server/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { ssoProvider, account, invitation, organization, sessionsTable } from "~/server/db/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { isReservedSsoProviderId } from "./utils/sso-provider-id";
|
||||
import { normalizeEmail } from "./utils/sso-context";
|
||||
|
||||
|
|
@ -83,9 +83,19 @@ export class SsoService {
|
|||
return true;
|
||||
}
|
||||
|
||||
const affectedAccounts = await tx.query.account.findMany({
|
||||
where: { providerId: provider.providerId },
|
||||
columns: { userId: true },
|
||||
});
|
||||
const affectedUserIds = [...new Set(affectedAccounts.map((row) => row.userId))];
|
||||
|
||||
await tx.delete(account).where(eq(account.providerId, provider.providerId));
|
||||
await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id));
|
||||
|
||||
if (affectedUserIds.length > 0) {
|
||||
await tx.delete(sessionsTable).where(inArray(sessionsTable.userId, affectedUserIds));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { validateCustomResticParams } from "../validate-custom-params";
|
||||
|
||||
describe("validateCustomResticParams", () => {
|
||||
test("accepts supported flags and values", () => {
|
||||
const result = validateCustomResticParams([
|
||||
"--no-scan",
|
||||
"--read-concurrency 8",
|
||||
"--exclude-larger-than 500M",
|
||||
"--pack-size=64",
|
||||
]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects positional arguments", () => {
|
||||
const result = validateCustomResticParams(["/etc"]);
|
||||
|
||||
expect(result).toContain('Unexpected positional argument "/etc"');
|
||||
});
|
||||
|
||||
test("rejects extra positional arguments after a flag value", () => {
|
||||
const result = validateCustomResticParams(["--read-concurrency 8 /etc"]);
|
||||
|
||||
expect(result).toContain('Unexpected positional argument "/etc"');
|
||||
});
|
||||
|
||||
test("rejects unsupported path-bearing flags", () => {
|
||||
const result = validateCustomResticParams(["--cache-dir /tmp/restic-cache"]);
|
||||
|
||||
expect(result).toContain('Unknown or unsupported flag "--cache-dir"');
|
||||
});
|
||||
|
||||
test("rejects dry-run flags", () => {
|
||||
expect(validateCustomResticParams(["--dry-run"])).toContain('Unknown or unsupported flag "--dry-run"');
|
||||
expect(validateCustomResticParams(["-n"])).toContain('Unknown or unsupported flag "-n"');
|
||||
});
|
||||
|
||||
test("rejects missing values for flags that require one", () => {
|
||||
const result = validateCustomResticParams(["--read-concurrency"]);
|
||||
|
||||
expect(result).toBe('Flag "--read-concurrency" requires a value');
|
||||
});
|
||||
});
|
||||
|
|
@ -14,56 +14,106 @@ const DENIED_FLAGS = new Set<string>([
|
|||
"--repo",
|
||||
]);
|
||||
|
||||
const ALLOWED_FLAGS = new Set<string>([
|
||||
"--verbose",
|
||||
"-v",
|
||||
"--no-scan",
|
||||
"--exclude-larger-than",
|
||||
"--skip-if-unchanged",
|
||||
"--exclude-caches",
|
||||
"--force",
|
||||
"--use-fs-snapshot",
|
||||
"--read-concurrency",
|
||||
"--ignore-ctime",
|
||||
"--ignore-inode",
|
||||
"--with-atime",
|
||||
"--no-cache",
|
||||
"--cleanup-cache",
|
||||
"--cache-dir",
|
||||
"--limit-upload",
|
||||
"--limit-download",
|
||||
"--pack-size",
|
||||
"--dry-run",
|
||||
"-n",
|
||||
"--no-lock",
|
||||
"--json",
|
||||
type FlagSpec = {
|
||||
requiresValue: boolean;
|
||||
validateValue?: (value: string) => string | null;
|
||||
};
|
||||
|
||||
const positiveIntegerValue = (value: string) => {
|
||||
return /^\d+$/.test(value) ? null : `Flag value "${value}" must be a positive integer`;
|
||||
};
|
||||
|
||||
const FLAG_SPECS = new Map<string, FlagSpec>([
|
||||
["--verbose", { requiresValue: false }],
|
||||
["-v", { requiresValue: false }],
|
||||
["--no-scan", { requiresValue: false }],
|
||||
["--exclude-larger-than", { requiresValue: true }],
|
||||
["--skip-if-unchanged", { requiresValue: false }],
|
||||
["--exclude-caches", { requiresValue: false }],
|
||||
["--force", { requiresValue: false }],
|
||||
["--use-fs-snapshot", { requiresValue: false }],
|
||||
["--read-concurrency", { requiresValue: true, validateValue: positiveIntegerValue }],
|
||||
["--ignore-ctime", { requiresValue: false }],
|
||||
["--ignore-inode", { requiresValue: false }],
|
||||
["--with-atime", { requiresValue: false }],
|
||||
["--no-cache", { requiresValue: false }],
|
||||
["--cleanup-cache", { requiresValue: false }],
|
||||
["--limit-upload", { requiresValue: true, validateValue: positiveIntegerValue }],
|
||||
["--limit-download", { requiresValue: true, validateValue: positiveIntegerValue }],
|
||||
["--pack-size", { requiresValue: true, validateValue: positiveIntegerValue }],
|
||||
["--no-lock", { requiresValue: false }],
|
||||
]);
|
||||
|
||||
function extractFlagName(token: string): string | null {
|
||||
if (!token.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
const ALLOWED_FLAGS = [...FLAG_SPECS.keys()].join(", ");
|
||||
|
||||
function parseFlagToken(token: string) {
|
||||
const eqIdx = token.indexOf("=");
|
||||
return eqIdx === -1 ? token : token.slice(0, eqIdx);
|
||||
|
||||
return eqIdx === -1
|
||||
? { flag: token, inlineValue: null as string | null }
|
||||
: { flag: token.slice(0, eqIdx), inlineValue: token.slice(eqIdx + 1) };
|
||||
}
|
||||
|
||||
function validateFlagValue(flag: string, value: string, spec: FlagSpec): string | null {
|
||||
if (!value) {
|
||||
return `Flag "${flag}" requires a value`;
|
||||
}
|
||||
|
||||
if (value.startsWith("-")) {
|
||||
return `Flag "${flag}" requires a value, but received "${value}"`;
|
||||
}
|
||||
|
||||
return spec.validateValue?.(value) ?? null;
|
||||
}
|
||||
|
||||
export function validateCustomResticParams(params: string[]): string | null {
|
||||
for (const param of params) {
|
||||
const tokens = param.trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
for (const token of tokens) {
|
||||
const flag = extractFlagName(token);
|
||||
if (flag === null) {
|
||||
continue;
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
|
||||
if (!token.startsWith("-")) {
|
||||
return `Unexpected positional argument "${token}" in customResticParams`;
|
||||
}
|
||||
|
||||
const { flag, inlineValue } = parseFlagToken(token);
|
||||
|
||||
if (DENIED_FLAGS.has(flag)) {
|
||||
return `Flag "${flag}" is not permitted in customResticParams`;
|
||||
}
|
||||
|
||||
if (!ALLOWED_FLAGS.has(flag)) {
|
||||
return `Unknown or unsupported flag "${flag}" in customResticParams. Permitted flags: ${[...ALLOWED_FLAGS].join(", ")}`;
|
||||
const spec = FLAG_SPECS.get(flag);
|
||||
if (!spec) {
|
||||
return `Unknown or unsupported flag "${flag}" in customResticParams. Permitted flags: ${ALLOWED_FLAGS}`;
|
||||
}
|
||||
|
||||
if (!spec.requiresValue) {
|
||||
if (inlineValue !== null && inlineValue !== "") {
|
||||
return `Flag "${flag}" does not accept a value`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inlineValue !== null) {
|
||||
const error = validateFlagValue(flag, inlineValue, spec);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextToken = tokens[index + 1];
|
||||
if (!nextToken) {
|
||||
return `Flag "${flag}" requires a value`;
|
||||
}
|
||||
|
||||
const error = validateFlagValue(flag, nextToken, spec);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue