diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 785a796e..1ee595ac 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -409,6 +409,7 @@ function Shell({ auth }: { auth: Auth }) {
}
if (
auth.user.onboarding_status === "pending" &&
+ auth.user.onboarding_required !== false &&
location.pathname !== "/onboarding"
)
return ;
diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts
index 7e3938a2..478be0c2 100644
--- a/apps/web/src/api/schema.d.ts
+++ b/apps/web/src/api/schema.d.ts
@@ -726,6 +726,24 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/setup/onboarding-policy": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Onboarding Policy */
+ get: operations["onboarding_policy_api_setup_onboarding_policy_get"];
+ /** Save Onboarding Policy */
+ put: operations["save_onboarding_policy_api_setup_onboarding_policy_put"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/recovery": {
parameters: {
query?: never;
@@ -10992,6 +11010,16 @@ export interface components {
* @default
*/
viewer_group: string;
+ /**
+ * Requester Group
+ * @default
+ */
+ requester_group: string;
+ /**
+ * Approver Group
+ * @default
+ */
+ approver_group: string;
};
/** OidcSettingsView */
OidcSettingsView: {
@@ -11029,6 +11057,10 @@ export interface components {
member_group: string;
/** Viewer Group */
viewer_group: string;
+ /** Requester Group */
+ requester_group: string;
+ /** Approver Group */
+ approver_group: string;
/** Secret Set */
secret_set: boolean;
/** Redirect Uri */
@@ -11048,6 +11080,14 @@ export interface components {
/** Reason */
reason: string;
};
+ /** OnboardingPolicy */
+ OnboardingPolicy: {
+ /**
+ * Show Member Onboarding
+ * @default true
+ */
+ show_member_onboarding: boolean;
+ };
/** OnboardingProgress */
OnboardingProgress: {
/**
@@ -14693,6 +14733,11 @@ export interface components {
* @default pending
*/
onboarding_status: string;
+ /**
+ * Onboarding Required
+ * @default true
+ */
+ onboarding_required: boolean;
/** Library Ids */
library_ids?: string[] | null;
};
@@ -16475,6 +16520,59 @@ export interface operations {
};
};
};
+ onboarding_policy_api_setup_onboarding_policy_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["OnboardingPolicy"];
+ };
+ };
+ };
+ };
+ save_onboarding_policy_api_setup_onboarding_policy_put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["OnboardingPolicy"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["OnboardingPolicy"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
review_api_recovery_get: {
parameters: {
query?: never;
diff --git a/apps/web/src/pages/Accounts.tsx b/apps/web/src/pages/Accounts.tsx
index dee0fec1..d94a18ce 100644
--- a/apps/web/src/pages/Accounts.tsx
+++ b/apps/web/src/pages/Accounts.tsx
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ApiError, result, setCsrf, type Auth } from "../api/client";
import type { components } from "../api/schema";
import BookDialog from "../components/BookDialog";
+import OnboardingPolicy from "./OnboardingPolicy";
import { Link } from "react-router-dom";
import { Loading, Notice } from "../components";
import {
@@ -58,27 +59,35 @@ function OidcSettingsPanel() {
const [secret, setSecret] = useState("");
const [open, setOpen] = useState(false);
useEffect(() => {
- if (settings.data) {
- setDraft(settings.data);
- setSecret("");
- }
+ const loaded = settings.data;
+ if (loaded) setDraft((current) => current ?? loaded);
}, [settings.data]);
const save = useMutation({
- mutationFn: async () => {
- if (!draft) return;
- const { secret_set, redirect_uri, ...fields } = draft;
+ mutationFn: async (submitted: { draft: OidcSettings; secret: string }) => {
+ const { secret_set, redirect_uri, ...fields } = submitted.draft;
void secret_set;
void redirect_uri;
return result(
await api.PUT("/api/auth/oidc/settings", {
- body: { ...fields, client_secret: secret },
+ body: { ...fields, client_secret: submitted.secret },
}),
);
},
- onSuccess: (saved) => {
- if (!saved) return;
+ onSuccess: (saved, submitted) => {
client.setQueryData(["oidc-settings"], saved);
- setSecret("");
+ // A completed save must not overwrite edits made while it was in flight.
+ setDraft((current) =>
+ current === submitted.draft
+ ? saved
+ : current
+ ? {
+ ...current,
+ secret_set: saved.secret_set,
+ redirect_uri: saved.redirect_uri,
+ }
+ : current,
+ );
+ setSecret((current) => (current === submitted.secret ? "" : current));
},
});
const discover = useMutation({
@@ -133,7 +142,7 @@ function OidcSettingsPanel() {
className="panel editor"
onSubmit={(event) => {
event.preventDefault();
- save.mutate();
+ if (draft) save.mutate({ draft, secret });
}}
>
@@ -236,12 +245,24 @@ function OidcSettingsPanel() {
update("default_role", event.target.value)
}
>
- Member
Viewer
+ Requester
+ Member
+ Approver
+ Administrator
)}
+ {(draft.auto_register || mapsGroups) &&
+ draft.default_role === "admin" && (
+
+ Administrator default grants full control to every new account
+ or synchronized account with no matching group. Choose it only
+ when everyone admitted by this provider should administer
+ Dewarr.
+
+ )}
{mapsGroups && (
Used when no configured group matches. Applies to existing
@@ -363,6 +384,32 @@ function OidcSettingsPanel() {
/>
+
+
+ Requester group
+
+ update("requester_group", event.target.value)
+ }
+ />
+
+
+ Approver group
+
+ update("approver_group", event.target.value)
+ }
+ />
+
+
+
+ If several groups match: Administrator, Approver, Member,
+ Requester, then Viewer. On each sign-in, mappings replace manual
+ roles for accounts without a local password. Password accounts
+ and the last active administrator keep their existing access.
+
Viewer group
@@ -788,6 +835,7 @@ export default function Accounts({ embedded = false }: { embedded?: boolean }) {
)}
{actor?.role === "admin" && (
<>
+
>
diff --git a/apps/web/src/pages/OnboardingPolicy.tsx b/apps/web/src/pages/OnboardingPolicy.tsx
new file mode 100644
index 00000000..e4e028b4
--- /dev/null
+++ b/apps/web/src/pages/OnboardingPolicy.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { api, result } from "../api/client";
+import { Loading, Notice } from "../components";
+
+export default function OnboardingPolicy() {
+ const cache = useQueryClient();
+ const policy = useQuery({
+ queryKey: ["onboarding-policy"],
+ queryFn: async () => result(await api.GET("/api/setup/onboarding-policy")),
+ });
+ const [draft, setDraft] = useState(null);
+ const show = draft ?? policy.data?.show_member_onboarding ?? true;
+ const save = useMutation({
+ mutationFn: async (submitted: boolean) =>
+ result(
+ await api.PUT("/api/setup/onboarding-policy", {
+ body: { show_member_onboarding: submitted },
+ }),
+ ),
+ onSuccess: (saved, submitted) => {
+ setDraft((current) => (current === submitted ? null : current));
+ cache.setQueryData(["onboarding-policy"], saved);
+ cache.invalidateQueries({ queryKey: ["session"] });
+ },
+ });
+ return (
+
+ First-run onboarding
+
+ Members and viewers can go straight to browsing on a managed
+ installation. They can always open their personal setup wizard from
+ Settings → General. Administrators, requesters, and approvers keep their
+ usual setup experience.
+
+ {policy.isPending ? (
+
+ ) : policy.data ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/tests/ui/library-review.spec.ts b/apps/web/tests/ui/library-review.spec.ts
index f77cd967..371f6412 100644
--- a/apps/web/tests/ui/library-review.spec.ts
+++ b/apps/web/tests/ui/library-review.spec.ts
@@ -28,6 +28,10 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
page.on("pageerror", (e) => errors.push(e.message));
const matchSearches: string[] = [];
const matches: { asset: string; body: unknown }[] = [];
+ let releaseNextSearch!: () => void;
+ const nextSearch = new Promise((resolve) => {
+ releaseNextSearch = resolve;
+ });
let imported = false;
const items = [
{ kind: "asset", asset: asset("a1", "Untagged Folder", ["title"]) },
@@ -115,6 +119,7 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
data = { items: shown, total: shown.length };
} else if (path === "/api/catalog/works") {
matchSearches.push(url.searchParams.get("q") || "");
+ if (url.searchParams.get("q") === "The Long Way") await nextSearch;
data = { items: [work], total: 1 };
} else if (path.endsWith("/match") && method === "POST") {
const id = path.split("/").at(-2)!;
@@ -214,9 +219,16 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
part_total: null,
},
});
- await expect(page.getByRole("status")).toContainText(
- "Linked “Untagged Folder”",
- );
+ try {
+ // Saving and continuing can announce success while the next lookup is pending.
+ await expect(detail.getByRole("status")).toHaveText("Searching catalog…");
+ await expect(
+ page.getByRole("status").filter({ hasText: "Linked “Untagged Folder”" }),
+ ).toBeVisible();
+ } finally {
+ releaseNextSearch();
+ }
+ await expect(detail.getByRole("status")).toHaveCount(0);
await expect(cards).toHaveCount(2);
await expect(
nav.getByRole("link", { name: /^Library review/ }),
diff --git a/apps/web/tests/ui/member-setup.spec.ts b/apps/web/tests/ui/member-setup.spec.ts
new file mode 100644
index 00000000..d3b438be
--- /dev/null
+++ b/apps/web/tests/ui/member-setup.spec.ts
@@ -0,0 +1,254 @@
+import { emptyDiscoveryHome, expect, test, type Page } from "../fixtures";
+
+type Reader = { role: string; status: string; required: boolean };
+
+async function readerRoutes(page: Page, reader: Reader) {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.route("**/api/**", (route) => {
+ const path = new URL(route.request().url()).pathname;
+ let data: unknown = [];
+ if (path === "/api/auth/me")
+ data = {
+ user: {
+ id: "reader",
+ username: "reader",
+ display_name: "Reader",
+ role: reader.role,
+ permissions: [],
+ onboarding_status: reader.status,
+ onboarding_required: reader.required,
+ },
+ csrf_token: "test",
+ };
+ else if (path === "/api/setup/onboarding")
+ data = { status: reader.status, step: 0, skipped: [] };
+ else if (path === "/api/discovery/home") data = emptyDiscoveryHome();
+ else if (path === "/api/metadata/account")
+ data = { connected: false, enabled: false };
+ else if (path === "/api/metadata/preferences")
+ data = { providers: ["openlibrary"], languages: ["en"] };
+ else if (path === "/api/setup/readiness")
+ data = { libraries: [], sources: [], downloaders: [], destinations: [] };
+ return route.fulfill({ json: data });
+ });
+ return errors;
+}
+
+for (const role of ["member", "viewer"]) {
+ test(`${role} can browse repeatedly with optional onboarding and reopen setup`, async ({
+ page,
+ }) => {
+ const errors = await readerRoutes(page, {
+ role,
+ status: "pending",
+ required: false,
+ });
+ await page.goto("/discover");
+ await expect(
+ page.getByRole("heading", { name: "Discover", exact: true }),
+ ).toBeAttached();
+ await expect(page).toHaveURL(/\/discover$/);
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "Discover", exact: true }),
+ ).toBeAttached();
+ await page.goto("/settings#display");
+ await page
+ .getByRole("button", { name: "Open onboarding setup wizard" })
+ .click();
+ await expect(page).toHaveURL(/\/onboarding$/);
+ await expect(
+ page.getByRole("heading", { name: "Let’s set up your library" }),
+ ).toBeVisible();
+ expect(errors).toEqual([]);
+ });
+}
+
+for (const role of ["member", "admin"]) {
+ test(`${role} still enters required first-run onboarding`, async ({
+ page,
+ }) => {
+ const errors = await readerRoutes(page, {
+ role,
+ status: "pending",
+ required: true,
+ });
+ await page.goto("/discover");
+ await expect(page).toHaveURL(/\/onboarding$/);
+ await expect(
+ page.getByRole("heading", { name: "Let’s set up your library" }),
+ ).toBeVisible();
+ expect(errors).toEqual([]);
+ });
+}
+
+test("completed setup remains dismissed", async ({ page }) => {
+ await readerRoutes(page, {
+ role: "member",
+ status: "completed",
+ required: false,
+ });
+ await page.goto("/discover");
+ await expect(
+ page.getByRole("heading", { name: "Discover", exact: true }),
+ ).toBeAttached();
+ await expect(page).toHaveURL(/\/discover$/);
+});
+
+test("administrator saves all OIDC presets, group mappings, and onboarding policy", async ({
+ page,
+}, testInfo) => {
+ const errors = await readerRoutes(page, {
+ role: "admin",
+ status: "completed",
+ required: false,
+ });
+ let policy = { show_member_onboarding: true };
+ let finishPolicySave: (() => void) | undefined;
+ let policyWrites = 0;
+ let oidc = {
+ enabled: true,
+ label: "Test ID",
+ issuer: "https://identity.example/application/o/dewarr/",
+ authorization_endpoint: "https://identity.example/authorize",
+ token_endpoint: "https://identity.example/token",
+ userinfo_endpoint: "https://identity.example/userinfo",
+ jwks_uri: "https://identity.example/jwks",
+ client_id: "dewarr",
+ secret_set: true,
+ redirect_uri: "https://books.example/api/auth/oidc/callback",
+ signing_algorithm: "RS256",
+ match_existing: "off",
+ auto_register: true,
+ default_role: "member",
+ group_claim: "groups",
+ group_scope: "",
+ admin_group: "admins",
+ member_group: "members",
+ viewer_group: "viewers",
+ requester_group: "",
+ approver_group: "",
+ };
+ const defaults: string[] = [];
+ let finishFirstSave: (() => void) | undefined;
+ await page.route("**/api/setup/onboarding-policy", async (route) => {
+ if (route.request().method() === "PUT") {
+ policy = route.request().postDataJSON();
+ policyWrites += 1;
+ if (policyWrites === 1)
+ await new Promise((resolve) => {
+ finishPolicySave = resolve;
+ });
+ }
+ return route.fulfill({ json: policy });
+ });
+ await page.route("**/api/auth/oidc/settings", async (route) => {
+ if (route.request().method() === "PUT") {
+ const body = route.request().postDataJSON();
+ expect(body.client_secret).toBe("");
+ expect(body.issuer).toBe(
+ "https://identity.example/application/o/dewarr/",
+ );
+ oidc = { ...oidc, ...body };
+ defaults.push(body.default_role);
+ if (defaults.length === 1)
+ await new Promise((resolve) => {
+ finishFirstSave = resolve;
+ });
+ }
+ return route.fulfill({ json: oidc });
+ });
+ await page.route("**/api/auth/access", (route) =>
+ route.fulfill({ json: { presets: [], permissions: [], roles: [] } }),
+ );
+ await page.goto("/settings#accounts");
+ const show = page.getByRole("checkbox", {
+ name: "Show onboarding wizard to new members and viewers",
+ });
+ await expect(show).toBeChecked();
+ await show.uncheck();
+ await page
+ .getByRole("button", { name: "Save onboarding preference" })
+ .click();
+ await expect.poll(() => policyWrites).toBe(1);
+ await show.check();
+ finishPolicySave!();
+ await expect(
+ page.getByRole("button", { name: "Save onboarding preference" }),
+ ).toBeEnabled();
+ await expect(show).toBeChecked();
+ await show.uncheck();
+ await page
+ .getByRole("button", { name: "Save onboarding preference" })
+ .click();
+ await expect.poll(() => policyWrites).toBe(2);
+ await expect(
+ page
+ .getByRole("status")
+ .filter({ hasText: "Onboarding preference saved." }),
+ ).toBeVisible();
+ expect(policy.show_member_onboarding).toBe(false);
+ await page.getByText("Identity provider", { exact: true }).click();
+ await page.getByText("Groups", { exact: true }).click();
+ await page
+ .getByLabel("Requester group", { exact: true })
+ .fill("family-requesters");
+ await page
+ .getByLabel("Approver group", { exact: true })
+ .fill("family-approvers");
+ for (const role of ["admin", "approver", "member", "viewer", "requester"]) {
+ await page
+ .getByRole("combobox", { name: "Default access", exact: true })
+ .selectOption(role);
+ if (role === "admin")
+ await expect(
+ page.getByText(/Administrator default grants full control/),
+ ).toBeVisible();
+ await page
+ .getByRole("button", { name: "Save identity provider", exact: true })
+ .click();
+ await expect.poll(() => defaults.at(-1)).toBe(role);
+ if (role === "admin") {
+ // An edit made before the response arrives must survive that older save.
+ await page
+ .getByRole("combobox", { name: "Default access", exact: true })
+ .selectOption("approver");
+ finishFirstSave!();
+ }
+ await expect(
+ page.getByRole("button", { name: "Save identity provider", exact: true }),
+ ).toBeEnabled();
+ await expect(
+ page.getByRole("combobox", { name: "Default access", exact: true }),
+ ).toHaveValue(role === "admin" ? "approver" : role);
+ }
+ await page.reload();
+ await expect(show).not.toBeChecked();
+ await page.getByText("Identity provider", { exact: true }).click();
+ await expect(
+ page.getByRole("combobox", { name: "Default access", exact: true }),
+ ).toHaveValue("requester");
+ await page.getByText("Groups", { exact: true }).click();
+ await expect(page.getByLabel("Requester group", { exact: true })).toHaveValue(
+ "family-requesters",
+ );
+ await expect(page.getByLabel("Approver group", { exact: true })).toHaveValue(
+ "family-approvers",
+ );
+ await expect(page.getByLabel("Issuer URL", { exact: true })).toHaveValue(
+ oidc.issuer,
+ );
+ expect(defaults).toEqual([
+ "admin",
+ "approver",
+ "member",
+ "viewer",
+ "requester",
+ ]);
+ expect(errors).toEqual([]);
+ await page.screenshot({
+ path: testInfo.outputPath("member-setup-settings.png"),
+ fullPage: true,
+ });
+});
diff --git a/docs/OIDC.md b/docs/OIDC.md
index ff3a28d0..cc4a742e 100644
--- a/docs/OIDC.md
+++ b/docs/OIDC.md
@@ -8,7 +8,9 @@ Create that first account in the browser before enabling the provider. Then open
`https://books.example.com/api/auth/oidc/callback`
-Copy it into the provider. Dewarr uses authorization code with PKCE (`S256`) and `client_secret_basic`. Correcting the issuer path keeps existing links when the scheme, host, and port stay the same. A different scheme, host, or port removes those links, so a reused subject cannot sign in as the old account.
+Copy it into the provider. Dewarr uses authorization code with PKCE (`S256`) and `client_secret_basic`. Keep the issuer exactly as published by the provider, including any trailing slash. Discovery and signed tokens must match that issuer exactly, as required by [OpenID Connect discovery validation](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationValidation).
+
+If an older Dewarr version removed the issuer's trailing slash, enter the exact issuer, discover endpoints, and save again. An explicit correction that adds or removes one trailing slash preserves existing account links; conflicting links reject the save. Saving the settings again keeps the issuer intact. Other issuer changes, including a different realm/path on the same host, remove the previous issuer's links. Re-link those accounts using their local passwords or your configured matching policy. A subject identifier from a different issuer is not the same identity.
## Link an existing account (including the first administrator)
@@ -32,9 +34,9 @@ From another administrator account, **Account details → Sign-in and account re
1. Create an application and choose the **OAuth2/OpenID Connect** provider. This is not the proxy provider.
2. Note the client ID and client secret.
3. Add the Dewarr redirect URL as a strict authorization redirect URI.
-4. In Dewarr, paste the issuer URL (`https://authentik.example/application/o/`), choose **Discover endpoints**, enter the client ID and secret, and save.
+4. In Dewarr, paste the issuer URL (`https://authentik.example/application/o//`), choose **Discover endpoints**, enter the client ID and secret, and save.
5. Leave **Match existing accounts** on username or verified email if these people already have Dewarr accounts. Verified email matches only when the provider sets `email_verified` to true. Username matching does not attach an administrator account. Turn on **Create accounts on first sign-in** only when the provider should add new readers.
-6. Optional: set the group claim to `groups` and enter the Authentik group names that should become administrators, members, or viewers. Leave **Group scope** empty unless Authentik releases that claim only when Dewarr requests a scope. A scope the provider does not offer rejects every provider sign-in.
+6. Optional: set the group claim to `groups` and enter the Authentik group names for Administrator, Approver, Member, Requester, or Viewer access. Leave **Group scope** empty unless Authentik releases that claim only when Dewarr requests a scope. A scope the provider does not offer rejects every provider sign-in.
A user's email must not be marked unverified. Authentik does that until the address is confirmed.
@@ -79,6 +81,28 @@ In Dewarr, the issuer is the Authelia address (`https://auth.example.com`). Disc
## Roles
-New provider accounts are members unless a group mapping says otherwise. They cannot run list automation until an administrator allows it. Group mapping does not change the role of an account that still has a local password, and it will not remove the last active administrator. Signing out of Dewarr ends the Dewarr session only; the identity provider session can remain.
+**New account access** (or **Default access** when a group claim is configured) supports all five built-in presets. Existing installations keep their saved default; new configurations still default to Member. Choose Requester when downloads must wait for approval. Selecting Administrator deliberately grants full control to accounts that receive the default.
+
+When several configured groups match, the first match in this order wins:
+
+1. Administrator
+2. Approver
+3. Member
+4. Requester
+5. Viewer
+
+When no group matches, the default applies. With a group claim configured, each sign-in synchronizes provider-only accounts to that preset, including its permissions; manual role changes are replaced at the next sign-in. A separate automation grant on a legacy Member account is retained while its mapping stays Member. Accounts with a local password retain their manual access, and a group change cannot demote the last active administrator. Without a group claim, the default applies only when creating an account; returning accounts retain their access.
+
+Requester accounts can ask for books but cannot bypass approval. Approvers can download and decide other people's requests. Neither preset grants library access or personal integration credentials. Non-administrator provider accounts start without list automation. Signing out of Dewarr ends the Dewarr session only; the identity provider session can remain.
People created by the provider have no Dewarr password. Disabling the provider blocks their sign-in until you turn it back on or give them a local account.
+
+## Optional member onboarding
+
+Under **Settings → Users & access → First-run onboarding**, administrators can turn off **Show onboarding wizard to new members and viewers**. It defaults to on. Turning it off allows pending Member/Viewer accounts to browse after local or OIDC sign-in, including subsequent sign-ins, without changing their saved onboarding progress. Administrators, Requesters, Approvers, and custom access presets keep their existing setup behavior.
+
+Everyone can reopen the optional wizard from **Settings → General → Open onboarding setup wizard**. Completed or deferred progress stays saved. This setting does not share personal Hardcover tokens, add library grants, or alter account permissions.
+
+## Issuer troubleshooting
+
+A discovery mismatch explains that the entered issuer must match exactly, including its slash. A signed-token issuer mismatch keeps the generic sign-in rejection in the browser and logs `issuer_mismatch` on the server. Logs do not include tokens, client secrets, or the received issuer claim. Check the configured issuer against the provider's published discovery document, then save its exact value.
diff --git a/docs/openapi.json b/docs/openapi.json
index b21726c6..ef3f66a8 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -1590,6 +1590,66 @@
}
}
},
+ "/api/setup/onboarding-policy": {
+ "get": {
+ "tags": [
+ "setup"
+ ],
+ "summary": "Onboarding Policy",
+ "operationId": "onboarding_policy_api_setup_onboarding_policy_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingPolicy"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "setup"
+ ],
+ "summary": "Save Onboarding Policy",
+ "operationId": "save_onboarding_policy_api_setup_onboarding_policy_put",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingPolicy"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingPolicy"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/recovery": {
"get": {
"tags": [
@@ -32457,6 +32517,18 @@
"maxLength": 120,
"title": "Viewer Group",
"default": ""
+ },
+ "requester_group": {
+ "type": "string",
+ "maxLength": 120,
+ "title": "Requester Group",
+ "default": ""
+ },
+ "approver_group": {
+ "type": "string",
+ "maxLength": 120,
+ "title": "Approver Group",
+ "default": ""
}
},
"additionalProperties": false,
@@ -32533,6 +32605,14 @@
"type": "string",
"title": "Viewer Group"
},
+ "requester_group": {
+ "type": "string",
+ "title": "Requester Group"
+ },
+ "approver_group": {
+ "type": "string",
+ "title": "Approver Group"
+ },
"secret_set": {
"type": "boolean",
"title": "Secret Set"
@@ -32561,6 +32641,8 @@
"admin_group",
"member_group",
"viewer_group",
+ "requester_group",
+ "approver_group",
"secret_set",
"redirect_uri"
],
@@ -32598,6 +32680,18 @@
],
"title": "OmittedSeriesBook"
},
+ "OnboardingPolicy": {
+ "properties": {
+ "show_member_onboarding": {
+ "type": "boolean",
+ "title": "Show Member Onboarding",
+ "default": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "OnboardingPolicy"
+ },
"OnboardingProgress": {
"properties": {
"status": {
@@ -42266,6 +42360,11 @@
"title": "Onboarding Status",
"default": "pending"
},
+ "onboarding_required": {
+ "type": "boolean",
+ "title": "Onboarding Required",
+ "default": true
+ },
"library_ids": {
"anyOf": [
{
diff --git a/services/app/api/auth.py b/services/app/api/auth.py
index 5b9e1306..df9540f5 100644
--- a/services/app/api/auth.py
+++ b/services/app/api/auth.py
@@ -21,6 +21,7 @@
LoginSession,
OidcIdentity,
OidcProvider,
+ OnboardingSettings,
PermissionRole,
PlexIdentity,
PlexLogin,
@@ -92,6 +93,7 @@ class UserView(BaseModel):
access_label: str
permission_role_id: str | None = None
onboarding_status: str = "pending"
+ onboarding_required: bool = True
library_ids: list[UUID] | None = None
@@ -111,7 +113,16 @@ async def named_user_view(db: Database, user: User) -> UserView:
role_name = await db.scalar(
select(PermissionRole.name).where(PermissionRole.id == user.permission_role_id)
)
- return user_view(user, role_name)
+ view = user_view(user, role_name)
+ view.onboarding_required = view.onboarding_status == "pending"
+ if (
+ view.onboarding_required
+ and not user.permission_role_id
+ and access_label(user) in {"Member", "Viewer"}
+ ):
+ policy = await db.get(OnboardingSettings, 1)
+ view.onboarding_required = policy.show_member_onboarding if policy else True
+ return view
def user_view(user: User, role_name: str | None = None, *, library_ids=None) -> UserView:
diff --git a/services/app/api/oidc.py b/services/app/api/oidc.py
index f510a7a8..fa0171d0 100644
--- a/services/app/api/oidc.py
+++ b/services/app/api/oidc.py
@@ -36,10 +36,10 @@
discover,
endpoint,
identity_claims,
+ is_issuer_slash_correction,
normalize_issuer,
redirect_uri,
resolve_user,
- same_provider_host,
same_secret,
snapshot,
)
@@ -85,6 +85,8 @@ class OidcSettingsInput(BaseModel):
admin_group: str = Field(default="", max_length=120)
member_group: str = Field(default="", max_length=120)
viewer_group: str = Field(default="", max_length=120)
+ requester_group: str = Field(default="", max_length=120)
+ approver_group: str = Field(default="", max_length=120)
@field_validator("client_secret", mode="before")
@classmethod
@@ -93,7 +95,9 @@ def blank_secret(cls, value):
return None
return value
- @field_validator("label", "admin_group", "member_group", "viewer_group")
+ @field_validator(
+ "label", "admin_group", "member_group", "viewer_group", "requester_group", "approver_group"
+ )
@classmethod
def trim(cls, value: str) -> str:
return " ".join(value.split())
@@ -124,6 +128,8 @@ class OidcSettingsView(BaseModel):
admin_group: str
member_group: str
viewer_group: str
+ requester_group: str
+ approver_group: str
secret_set: bool
redirect_uri: str
@@ -161,6 +167,8 @@ def settings_view(row: OidcProvider | None) -> OidcSettingsView:
admin_group=row.admin_group if row else "",
member_group=row.member_group if row else "",
viewer_group=row.viewer_group if row else "",
+ requester_group=row.requester_group if row else "",
+ approver_group=row.approver_group if row else "",
secret_set=bool(row and row.encrypted_secret),
redirect_uri=redirect_uri(),
)
@@ -171,8 +179,8 @@ def validate_settings(body: OidcSettingsInput) -> str:
raise HTTPException(422, "Choose RS256 or ES256")
if body.match_existing not in {"off", "email", "username"}:
raise HTTPException(422, "Choose how existing accounts are matched")
- if body.default_role not in {"member", "viewer"}:
- raise HTTPException(422, "New accounts start as members or viewers")
+ if body.default_role not in {"admin", "approver", "member", "requester", "viewer"}:
+ raise HTTPException(422, "Choose a built-in role for default access")
if body.group_claim and not CLAIM_NAME.fullmatch(body.group_claim):
raise HTTPException(422, "Use a group claim name such as groups")
if body.group_scope and not SCOPE_NAME.fullmatch(body.group_scope):
@@ -420,7 +428,17 @@ async def save_oidc_settings(body: OidcSettingsInput, admin: Admin, db: Database
raise HTTPException(422, "A client secret is required")
if row is not None and row.issuer and issuer and row.issuer != issuer:
identities = OidcIdentity.issuer == row.issuer
- if same_provider_host(row.issuer, issuer):
+ if is_issuer_slash_correction(row.issuer, issuer):
+ conflict = await db.scalar(
+ select(OidcIdentity.user_id)
+ .where(
+ OidcIdentity.issuer == issuer,
+ OidcIdentity.subject.in_(select(OidcIdentity.subject).where(identities)),
+ )
+ .limit(1)
+ )
+ if conflict:
+ raise HTTPException(409, "The corrected issuer has conflicting account links")
await db.execute(update(OidcIdentity).where(identities).values(issuer=issuer))
else:
await db.execute(delete(OidcIdentity).where(identities))
@@ -444,6 +462,8 @@ async def save_oidc_settings(body: OidcSettingsInput, admin: Admin, db: Database
row.admin_group = body.admin_group
row.member_group = body.member_group
row.viewer_group = body.viewer_group
+ row.requester_group = body.requester_group
+ row.approver_group = body.approver_group
if body.client_secret is not None:
row.encrypted_secret = encrypt_secrets(
{"client_secret": body.client_secret.get_secret_value()}
@@ -460,6 +480,12 @@ async def discover_provider(body: DiscoverInput, admin: Admin, db: Database):
except ValueError as error:
raise HTTPException(422, str(error)) from None
except OidcRejected as error:
+ if error.reason == "discovery_issuer_mismatch":
+ raise HTTPException(
+ 422,
+ "The discovery issuer must exactly match the entered issuer URL, "
+ "including its trailing slash",
+ ) from None
raise HTTPException(
422 if error.code == "rejected" else 502, ERRORS.get(error.code, ERRORS["unavailable"])
) from None
@@ -566,6 +592,8 @@ def failed(code: str):
return response
user = await resolve_user(db, provider, claims)
except OidcRejected as error:
+ if error.reason:
+ logger.info("OIDC sign-in validation failed (%s)", error.reason)
await db.rollback()
return failed(error.code if error.code in ERRORS else "rejected")
except IntegrityError:
diff --git a/services/app/api/setup.py b/services/app/api/setup.py
index 0ef1e481..e276655d 100644
--- a/services/app/api/setup.py
+++ b/services/app/api/setup.py
@@ -5,15 +5,18 @@
from uuid import UUID
from fastapi import APIRouter
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
+from sqlalchemy.dialects.postgresql import insert
from app.api.dependencies import Admin, CurrentUser, Database
from app.db.models import (
+ AuditEvent,
CatalogAccount,
ImportDestination,
Integration,
Library,
+ OnboardingSettings,
SourceConnection,
User,
)
@@ -177,3 +180,28 @@ async def save_onboarding(body: OnboardingProgress, user: CurrentUser, db: Datab
account.onboarding = body.model_dump()
await db.commit()
return body
+
+
+class OnboardingPolicy(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+ show_member_onboarding: bool = True
+
+
+@router.get("/onboarding-policy", response_model=OnboardingPolicy)
+async def onboarding_policy(admin: Admin, db: Database):
+ row = await db.get(OnboardingSettings, 1)
+ return OnboardingPolicy(show_member_onboarding=row.show_member_onboarding if row else True)
+
+
+@router.put("/onboarding-policy", response_model=OnboardingPolicy)
+async def save_onboarding_policy(body: OnboardingPolicy, admin: Admin, db: Database):
+ await db.execute(
+ insert(OnboardingSettings)
+ .values(id=1, **body.model_dump())
+ .on_conflict_do_update(index_elements=[OnboardingSettings.id], set_=body.model_dump())
+ )
+ db.add(
+ AuditEvent(actor_id=admin.id, action="onboarding.policy_changed", detail=body.model_dump())
+ )
+ await db.commit()
+ return body
diff --git a/services/app/db/migrations/versions/0080_member_setup.py b/services/app/db/migrations/versions/0080_member_setup.py
new file mode 100644
index 00000000..23197e3f
--- /dev/null
+++ b/services/app/db/migrations/versions/0080_member_setup.py
@@ -0,0 +1,48 @@
+"""Support all OIDC role presets and optional member onboarding."""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0080_member_setup"
+down_revision = "0079_book_coverage_index"
+branch_labels = depends_on = None
+
+
+def upgrade():
+ for name in ("requester_group", "approver_group"):
+ op.add_column(
+ "oidc_provider", sa.Column(name, sa.String(120), nullable=False, server_default="")
+ )
+ op.drop_constraint("oidc_provider_default_role_check", "oidc_provider", type_="check")
+ op.create_check_constraint(
+ "oidc_provider_default_role_check",
+ "oidc_provider",
+ "default_role IN ('admin', 'approver', 'member', 'requester', 'viewer')",
+ )
+ op.create_table(
+ "onboarding_settings",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("show_member_onboarding", sa.Boolean(), nullable=False, server_default=sa.true()),
+ sa.CheckConstraint("id = 1"),
+ )
+
+
+def downgrade():
+ if op.get_bind().scalar(
+ sa.text(
+ "SELECT EXISTS (SELECT 1 FROM oidc_provider "
+ "WHERE default_role NOT IN ('member', 'viewer') "
+ "OR requester_group <> '' OR approver_group <> '') "
+ "OR EXISTS (SELECT 1 FROM onboarding_settings WHERE NOT show_member_onboarding)"
+ )
+ ):
+ raise RuntimeError(
+ "Restore a pre-upgrade backup rather than dropping member setup settings"
+ )
+ op.drop_table("onboarding_settings")
+ op.drop_constraint("oidc_provider_default_role_check", "oidc_provider", type_="check")
+ op.create_check_constraint(
+ "oidc_provider_default_role_check", "oidc_provider", "default_role IN ('member', 'viewer')"
+ )
+ op.drop_column("oidc_provider", "approver_group")
+ op.drop_column("oidc_provider", "requester_group")
diff --git a/services/app/db/models.py b/services/app/db/models.py
index ea9ab9af..e57cb4dc 100644
--- a/services/app/db/models.py
+++ b/services/app/db/models.py
@@ -106,7 +106,7 @@ class OidcProvider(Base):
__table_args__ = (
CheckConstraint("id = 1"),
CheckConstraint("match_existing IN ('off', 'email', 'username')"),
- CheckConstraint("default_role IN ('member', 'viewer')"),
+ CheckConstraint("default_role IN ('admin', 'approver', 'member', 'requester', 'viewer')"),
CheckConstraint("signing_algorithm IN ('RS256', 'ES256')"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -128,6 +128,8 @@ class OidcProvider(Base):
admin_group: Mapped[str] = mapped_column(String(120), default="")
member_group: Mapped[str] = mapped_column(String(120), default="")
viewer_group: Mapped[str] = mapped_column(String(120), default="")
+ requester_group: Mapped[str] = mapped_column(String(120), default="", server_default="")
+ approver_group: Mapped[str] = mapped_column(String(120), default="", server_default="")
class PlexIdentity(Base):
@@ -1202,6 +1204,15 @@ class DownloadIdentityClaim(Identity, Base):
active: Mapped[bool] = mapped_column(Boolean, default=True)
+class OnboardingSettings(Base):
+ __tablename__ = "onboarding_settings"
+ __table_args__ = (CheckConstraint("id = 1"),)
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ show_member_onboarding: Mapped[bool] = mapped_column(
+ Boolean, default=True, server_default="true"
+ )
+
+
class OrganizationSettings(Base):
__tablename__ = "organization_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
diff --git a/services/app/domain/oidc.py b/services/app/domain/oidc.py
index 99527488..7aa51faf 100644
--- a/services/app/domain/oidc.py
+++ b/services/app/domain/oidc.py
@@ -5,7 +5,7 @@
import re
from dataclasses import dataclass
from typing import Any
-from urllib.parse import urlencode, urlsplit, urlunsplit
+from urllib.parse import urlencode, urlsplit
import httpx
from authlib.oauth2.rfc7636 import create_s256_code_challenge
@@ -26,8 +26,9 @@
class OidcRejected(Exception):
- def __init__(self, code: str):
+ def __init__(self, code: str, *, reason: str | None = None):
self.code = code
+ self.reason = reason
@dataclass(frozen=True)
@@ -49,6 +50,8 @@ class Provider:
member_group: str
viewer_group: str
label: str
+ requester_group: str = ""
+ approver_group: str = ""
def allowed_scheme() -> str:
@@ -59,20 +62,13 @@ def redirect_uri() -> str:
return get_settings().public_url + "/api/auth/oidc/callback"
-def _issuer_socket(value: str) -> tuple[str, str, int] | None:
- parts = urlsplit(value)
- host = (parts.hostname or "").lower()
- if parts.scheme not in {"http", "https"} or not host:
- return None
- port = parts.port
- if port is None:
- port = 443 if parts.scheme == "https" else 80
- return (parts.scheme, host, port)
-
-
-def same_provider_host(left: str, right: str) -> bool:
- left_socket = _issuer_socket(left)
- return left_socket is not None and left_socket == _issuer_socket(right)
+def is_issuer_slash_correction(previous: str, current: str) -> bool:
+ # Only an administrator's explicit slash correction can migrate legacy links.
+ # Other paths on the same host can represent independent identity providers.
+ return bool(previous and current) and (
+ (not previous.endswith("/") and previous + "/" == current)
+ or (not current.endswith("/") and current + "/" == previous)
+ )
def normalize_issuer(value: str, *, scheme: str | None = None) -> str:
@@ -91,11 +87,10 @@ def normalize_issuer(value: str, *, scheme: str | None = None) -> str:
or ".." in parts.path.split("/")
):
raise ValueError("Use the issuer URL from the identity provider")
- path = parts.path.rstrip("/")
- issuer = urlunsplit((parts.scheme, parts.netloc, path, "", "")).rstrip("/")
- if len(issuer) > 300:
+ # The issuer is an identifier, not a URL to canonicalize.
+ if len(cleaned) > 300:
raise ValueError("Use the issuer URL from the identity provider")
- return issuer
+ return cleaned
def same_origin(url: str, issuer: str) -> bool:
@@ -157,6 +152,8 @@ def snapshot(row: OidcProvider | None) -> Provider | None:
admin_group=row.admin_group,
member_group=row.member_group,
viewer_group=row.viewer_group,
+ requester_group=row.requester_group,
+ approver_group=row.approver_group,
label=row.label,
)
@@ -218,10 +215,12 @@ async def fetch_json(
async def discover(issuer: str) -> dict[str, str]:
root = normalize_issuer(issuer)
- document = await fetch_json("GET", root + "/.well-known/openid-configuration", origin=root)
+ document = await fetch_json(
+ "GET", root.rstrip("/") + "/.well-known/openid-configuration", origin=root
+ )
announced = document.get("issuer")
- if not isinstance(announced, str) or announced.rstrip("/") != root:
- raise OidcRejected("rejected")
+ if announced != root:
+ raise OidcRejected("rejected", reason="discovery_issuer_mismatch")
found = {"issuer": root}
for name in (
"authorization_endpoint",
@@ -297,6 +296,8 @@ async def identity_claims(
decoded = jwt.decode(
id_token, jwk.KeySet.import_key_set(jwks), algorithms=[provider.signing_algorithm]
)
+ if decoded.claims.get("iss") != provider.issuer:
+ raise OidcRejected("rejected", reason="issuer_mismatch")
jwt.JWTClaimsRegistry(
leeway=60,
iss={"essential": True, "value": provider.issuer},
@@ -385,13 +386,19 @@ def role_from_groups(
member_group: str,
viewer_group: str,
default: str,
+ requester_group: str = "",
+ approver_group: str = "",
) -> str:
values = groups if isinstance(groups, list) else [groups]
names = {item for item in values if isinstance(item, str) and item}
if admin_group and admin_group in names:
return "admin"
+ if approver_group and approver_group in names:
+ return "approver"
if member_group and member_group in names:
return "member"
+ if requester_group and requester_group in names:
+ return "requester"
if viewer_group and viewer_group in names:
return "viewer"
return default
@@ -414,6 +421,8 @@ async def _sync_role(db, user: User, provider: Provider, claims: dict[str, Any])
admin_group=provider.admin_group,
member_group=provider.member_group,
viewer_group=provider.viewer_group,
+ requester_group=provider.requester_group,
+ approver_group=provider.approver_group,
default=provider.default_role,
)
if user.role == "admin" and role != "admin":
@@ -426,11 +435,9 @@ async def _sync_role(db, user: User, provider: Provider, claims: dict[str, Any])
)
if not others:
return
- if user.role == role:
- return
# Null permissions follow the role. Explicit grants have to move with it, or a
# demoted administrator would keep the stored administrator bits.
- if user.permissions is None:
+ if user.permissions is None and role in {"admin", "member", "viewer"}:
user.role = role
if role == "viewer":
user.can_automate = False
@@ -513,9 +520,13 @@ async def resolve_user(db, provider: Provider, claims: dict[str, Any]) -> User:
display_name=display_name(claims, username),
password_hash=None,
email=email,
- role=provider.default_role,
+ role=provider.default_role
+ if provider.default_role in {"admin", "member", "viewer"}
+ else "member",
can_automate=False,
)
+ if provider.default_role in {"requester", "approver"}:
+ sync_user_permissions(user, preset_bits(provider.default_role))
db.add(user)
await db.flush()
if email:
diff --git a/services/app/state_bundle.py b/services/app/state_bundle.py
index b802260e..ead83b55 100644
--- a/services/app/state_bundle.py
+++ b/services/app/state_bundle.py
@@ -24,7 +24,7 @@
from app.db.models import Base
from app.recovery import MAINTENANCE_LOCK
-SCHEMA = "0079_book_coverage_index"
+SCHEMA = "0080_member_setup"
CONFIG_FIELDS = {
"public_url",
"cookie_secure",
diff --git a/tests/integration/test_member_setup_migration.py b/tests/integration/test_member_setup_migration.py
new file mode 100644
index 00000000..6621a1b2
--- /dev/null
+++ b/tests/integration/test_member_setup_migration.py
@@ -0,0 +1,66 @@
+"""Upgrade existing settings without changing identity or access defaults."""
+
+from importlib import import_module
+
+import pytest
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy import create_engine, text
+
+from app.config import get_settings
+
+pytestmark = pytest.mark.integration
+
+
+def test_member_setup_migration_preserves_existing_configuration(migrated_database):
+ migration = import_module("app.db.migrations.versions.0080_member_setup")
+ engine = create_engine(get_settings().database_url.get_secret_value())
+ try:
+ with engine.connect() as connection, connection.begin():
+ # All schema changes and fixture rows are rolled back after this check.
+ connection.execute(text("DELETE FROM oidc_provider"))
+ connection.execute(text("DELETE FROM onboarding_settings"))
+ with Operations.context(MigrationContext.configure(connection)):
+ migration.downgrade()
+ connection.execute(
+ text("""
+ INSERT INTO oidc_provider
+ (id, enabled, label, issuer, authorization_endpoint, token_endpoint,
+ userinfo_endpoint, jwks_uri, client_id, encrypted_secret, signing_algorithm,
+ match_existing, auto_register, default_role, group_claim, group_scope,
+ admin_group, member_group, viewer_group)
+ VALUES (1, true, 'Existing provider', 'https://id.example/realm/',
+ 'https://id.example/auth', 'https://id.example/token',
+ 'https://id.example/userinfo', 'https://id.example/jwks', 'client',
+ 'opaque-encrypted-secret', 'RS256', 'email', true, 'viewer', 'groups', '',
+ 'admins', 'readers', 'guests')
+ """)
+ )
+ before = dict(
+ connection.execute(text("SELECT * FROM oidc_provider")).mappings().one()
+ )
+ migration.upgrade()
+ after = dict(
+ connection.execute(text("SELECT * FROM oidc_provider")).mappings().one()
+ )
+ assert {key: after[key] for key in before} == before
+ assert after["requester_group"] == after["approver_group"] == ""
+ connection.execute(text("INSERT INTO onboarding_settings (id) VALUES (1)"))
+ assert (
+ connection.scalar(
+ text("SELECT show_member_onboarding FROM onboarding_settings")
+ )
+ is True
+ )
+ connection.execute(text("UPDATE oidc_provider SET default_role = 'requester'"))
+ with pytest.raises(RuntimeError, match="pre-upgrade backup"):
+ migration.downgrade()
+ connection.execute(text("UPDATE oidc_provider SET default_role = 'viewer'"))
+ connection.execute(
+ text("UPDATE onboarding_settings SET show_member_onboarding = false")
+ )
+ with pytest.raises(RuntimeError, match="pre-upgrade backup"):
+ migration.downgrade()
+ connection.rollback()
+ finally:
+ engine.dispose()
diff --git a/tests/integration/test_oidc.py b/tests/integration/test_oidc.py
index 29ec3351..42b6a7ef 100644
--- a/tests/integration/test_oidc.py
+++ b/tests/integration/test_oidc.py
@@ -13,7 +13,7 @@
from joserfc import jwk, jwt
from sqlalchemy import func, select
-from app.db.models import OidcIdentity, User
+from app.db.models import LibraryGrant, OidcIdentity, OidcProvider, User
from app.main import create_app
pytestmark = pytest.mark.integration
@@ -53,21 +53,23 @@ def log_message(self, message, *args):
def do_GET(self):
path = urlsplit(self.path).path
server = self.server
- if path == "/.well-known/openid-configuration":
+ root = urlsplit(server.issuer).path.rstrip("/")
+ base = server.issuer.rstrip("/")
+ if path == root + "/.well-known/openid-configuration":
self._send(
200,
{
- "issuer": server.issuer,
- "authorization_endpoint": server.issuer + "/authorize",
- "token_endpoint": server.issuer + "/token",
- "userinfo_endpoint": server.issuer + "/userinfo",
- "jwks_uri": server.issuer + "/jwks",
+ "issuer": server.discovery_issuer or server.issuer,
+ "authorization_endpoint": base + "/authorize",
+ "token_endpoint": base + "/token",
+ "userinfo_endpoint": base + "/userinfo",
+ "jwks_uri": base + "/jwks",
"id_token_signing_alg_values_supported": ["RS256"],
},
)
- elif path == "/jwks":
+ elif path == root + "/jwks":
self._send(200, {"keys": [PUBLIC]})
- elif path == "/userinfo":
+ elif path == root + "/userinfo":
if self.headers.get("Authorization") != "Bearer access-token":
self._send(401, {"error": "invalid_token"})
return
@@ -93,7 +95,7 @@ def do_POST(self):
token = jwt.encode(
{"alg": "RS256", "kid": "test"},
{
- "iss": server.issuer,
+ "iss": server.token_issuer or server.issuer,
"sub": server.sub,
"aud": server.aud,
"exp": now + 300,
@@ -131,6 +133,8 @@ def reset(self):
self.groups = ["members"]
self.aud = "dewarr"
self.reject_token = False
+ self.discovery_issuer = None
+ self.token_issuer = None
self.signing_key = PRIVATE
def profile(self):
@@ -445,7 +449,7 @@ async def test_groups_do_not_change_password_accounts(client, admin, idp):
assert me.json()["user"]["role"] == "member"
-async def test_issuer_change_keeps_links_only_on_the_same_socket(client, admin, database, idp):
+async def test_issuer_change_does_not_transfer_links_between_realms(client, admin, database, idp):
await save_provider(client, idp, auto_register=True)
state = await start(client, idp)
assert (await callback(client, state)).status_code == 303
@@ -475,9 +479,7 @@ async def save_issuer(issuer: str):
corrected = idp.issuer + "/application/o/dewarr"
await save_issuer(corrected)
async with database() as db:
- identity = await db.scalar(select(OidcIdentity))
- assert identity.issuer == corrected
- assert identity.subject == "subject-1"
+ assert await db.scalar(select(OidcIdentity)) is None
other_port = 9 if urlsplit(idp.issuer).port != 9 else 10
await save_issuer(f"http://127.0.0.1:{other_port}/application/o/dewarr")
async with database() as db:
@@ -682,7 +684,7 @@ async def test_explicit_link_rejects_invalidated_attempts(
from sqlalchemy import update
- from app.db.models import OidcLinkAttempt, OidcProvider
+ from app.db.models import OidcLinkAttempt
await save_provider(client, idp)
state = await begin_account_link(client, idp)
@@ -723,3 +725,198 @@ async def test_explicit_link_requires_session_and_csrf(client, admin, idp):
assert (
await client.post("/api/auth/oidc/link", json={"password": "a long test password"})
).status_code == 401
+
+
+async def login_admin(client):
+ client.cookies.clear()
+ login = await client.post(
+ "/api/auth/login",
+ json={"username": "admin", "password": "a long test password"},
+ )
+ assert login.status_code == 200, login.text
+ client.headers["X-CSRF-Token"] = login.json()["csrf_token"]
+
+
+@pytest.mark.parametrize("suffix", ["/application/o/dewarr/", "/application/o/dewarr"])
+async def test_exact_issuer_survives_discovery_save_and_returning_sign_in(
+ client, admin, database, idp, suffix
+):
+ idp.issuer += suffix
+ saved = await save_provider(client, idp, auto_register=True)
+ assert saved["issuer"] == idp.issuer
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ first = (await client.get("/api/auth/me")).json()["user"]
+ await login_admin(client)
+ await save_provider(client, idp, auto_register=True, client_secret="")
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ assert (await client.get("/api/auth/me")).json()["user"]["id"] == first["id"]
+ assert await user_count(database) == 2
+
+
+async def test_correcting_legacy_issuer_retains_account_links(client, admin, database, idp):
+ await save_provider(client, idp, auto_register=True)
+ await callback(client, await start(client, idp))
+ first = (await client.get("/api/auth/me")).json()["user"]
+ # Existing installations stored the issuer without its slash. Correct it via settings.
+ idp.issuer += "/"
+ await login_admin(client)
+ await save_provider(client, idp, auto_register=False)
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ assert (await client.get("/api/auth/me")).json()["user"]["id"] == first["id"]
+ async with database() as db:
+ assert (await db.scalar(select(OidcIdentity))).issuer == idp.issuer
+ assert await user_count(database) == 2
+
+
+@pytest.mark.parametrize("difference", ["/", "/other-realm"])
+async def test_discovery_requires_exact_issuer(client, admin, idp, difference):
+ idp.discovery_issuer = idp.issuer + difference
+ response = await client.post("/api/auth/oidc/discover", json={"issuer": idp.issuer})
+ assert response.status_code == 422
+
+
+@pytest.mark.parametrize("difference", ["/", "/other-realm"])
+async def test_signed_tokens_require_exact_issuer(client, admin, database, idp, caplog, difference):
+ await save_provider(client, idp, auto_register=True)
+ idp.token_issuer = idp.issuer + difference
+ with caplog.at_level("INFO", logger="app.api.oidc"):
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/?oidc_error=rejected"
+ assert "issuer_mismatch" in caplog.text
+ assert "test-client-secret" not in caplog.text
+ assert "access-token" not in caplog.text
+ assert await user_count(database) == 1
+
+
+@pytest.mark.parametrize(
+ "role,label",
+ [
+ ("admin", "Administrator"),
+ ("approver", "Approver"),
+ ("member", "Member"),
+ ("requester", "Requester"),
+ ("viewer", "Viewer"),
+ ],
+)
+@pytest.mark.parametrize("mapped", [False, True])
+async def test_all_oidc_presets_apply_permissions_on_first_and_returning_sign_in(
+ client, admin, database, idp, role, label, mapped
+):
+ settings = {"auto_register": True, "default_role": role}
+ if mapped:
+ settings.update(default_role="viewer", group_claim="groups", **{role + "_group": "access"})
+ idp.groups = ["access"]
+ saved = await save_provider(client, idp, **settings)
+ assert saved["default_role"] == settings["default_role"]
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ first = (await client.get("/api/auth/me")).json()["user"]
+ assert first["access_label"] == label
+ permissions = first["permissions"]
+ assert ("manage_requests" in permissions) == (role in {"admin", "approver"})
+ assert ("auto_approve" in permissions) == (role in {"admin", "approver", "member"})
+ assert ("request" in permissions) == (role != "viewer")
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ returning = (await client.get("/api/auth/me")).json()["user"]
+ assert returning["id"] == first["id"]
+ assert returning["permissions"] == permissions
+ async with database() as db:
+ assert await db.scalar(select(func.count()).select_from(LibraryGrant)) == 0
+ assert await user_count(database) == 2
+
+
+async def test_oidc_group_precedence_and_permission_changes(client, admin, database, idp):
+ await save_provider(
+ client,
+ idp,
+ auto_register=True,
+ default_role="viewer",
+ group_claim="groups",
+ admin_group="admins",
+ approver_group="approvers",
+ member_group="members",
+ requester_group="requesters",
+ viewer_group="viewers",
+ )
+ groups = ["admins", "approvers", "members", "requesters", "viewers"]
+ labels = ["Administrator", "Approver", "Member", "Requester", "Viewer", "Viewer"]
+ for offset, label in enumerate(labels):
+ idp.groups = groups[offset:]
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ me = (await client.get("/api/auth/me")).json()["user"]
+ assert me["access_label"] == label
+ # An administrator's manual promotion of a provider-only account is replaced
+ # at its next sign-in, even when both presets use the legacy member role.
+ async with database() as db, db.begin():
+ from app.domain.permissions import preset_bits, sync_user_permissions
+
+ user = await db.scalar(select(User).where(User.username == "reader"))
+ sync_user_permissions(user, preset_bits("approver"))
+ idp.groups = ["requesters"]
+ await callback(client, await start(client, idp))
+ assert (await client.get("/api/auth/me")).json()["user"]["access_label"] == "Requester"
+ idp.groups = ["members"]
+ await callback(client, await start(client, idp))
+ assert (await client.get("/api/auth/me")).json()["user"]["access_label"] == "Member"
+ assert await user_count(database) == 2
+
+
+async def test_oidc_mapping_cannot_demote_the_last_active_admin(client, admin, database, idp):
+ await save_provider(
+ client,
+ idp,
+ auto_register=True,
+ group_claim="groups",
+ admin_group="admins",
+ requester_group="requesters",
+ )
+ idp.groups = ["admins"]
+ await callback(client, await start(client, idp))
+ async with database() as db, db.begin():
+ local = await db.scalar(select(User).where(User.username == "admin"))
+ local.active = False
+ idp.groups = ["requesters"]
+ response = await callback(client, await start(client, idp))
+ assert response.headers["location"] == "/"
+ assert (await client.get("/api/auth/me")).json()["user"]["role"] == "admin"
+
+
+async def test_slash_correction_conflict_does_not_reassign_accounts(client, admin, database, idp):
+ await save_provider(client, idp, auto_register=True)
+ await callback(client, await start(client, idp))
+ original = idp.issuer
+ async with database() as db, db.begin():
+ local = await db.scalar(select(User).where(User.username == "admin"))
+ db.add(OidcIdentity(user_id=local.id, issuer=original + "/", subject=idp.sub))
+ await login_admin(client)
+ current = (await client.get("/api/auth/oidc/settings")).json()
+ current.pop("secret_set")
+ current.pop("redirect_uri")
+ response = await client.put(
+ "/api/auth/oidc/settings", json={**current, "issuer": original + "/"}
+ )
+ assert response.status_code == 409
+ async with database() as db:
+ assert (await db.get(OidcProvider, 1)).issuer == original
+ assert await db.scalar(select(func.count()).select_from(OidcIdentity)) == 2
+
+
+@pytest.mark.parametrize("role", ["member", "viewer", "requester", "approver", "admin"])
+async def test_oidc_onboarding_policy_applies_at_sign_in(client, admin, database, idp, role):
+ await save_provider(client, idp, auto_register=True, default_role=role)
+ policy = await client.put(
+ "/api/setup/onboarding-policy", json={"show_member_onboarding": False}
+ )
+ assert policy.status_code == 200
+ for _ in range(2):
+ await callback(client, await start(client, idp))
+ me = (await client.get("/api/auth/me")).json()["user"]
+ assert me["onboarding_status"] == "pending"
+ assert me["onboarding_required"] == (role not in {"member", "viewer"})
+ async with database() as db:
+ assert await db.scalar(select(func.count()).select_from(LibraryGrant)) == 0
diff --git a/tests/integration/test_onboarding.py b/tests/integration/test_onboarding.py
index c8684779..486b0117 100644
--- a/tests/integration/test_onboarding.py
+++ b/tests/integration/test_onboarding.py
@@ -1,4 +1,9 @@
+from uuid import UUID
+
import pytest
+from sqlalchemy import select
+
+from app.db.models import CatalogAccount, Integration, Library, LibraryGrant
pytestmark = pytest.mark.integration
@@ -64,3 +69,199 @@ async def test_progress_requires_auth_and_csrf(client, admin):
).status_code == 403
client.cookies.clear()
assert (await client.get("/api/setup/onboarding")).status_code == 401
+
+
+@pytest.mark.parametrize("role", ["member", "viewer", "requester", "approver", "admin"])
+async def test_admin_policy_controls_local_sign_in_without_changing_progress(client, admin, role):
+ assert (await client.get("/api/setup/onboarding-policy")).json() == {
+ "show_member_onboarding": True
+ }
+ created = await client.post(
+ "/api/auth/users",
+ json={
+ "username": "new-reader",
+ "display_name": "Reader",
+ "role": role,
+ "password": "a separate long password",
+ },
+ )
+ assert created.status_code == 201
+ original = created.json()
+ login_body = {"username": "new-reader", "password": "a separate long password"}
+ first = await client.post("/api/auth/login", json=login_body)
+ assert first.json()["user"]["onboarding_required"] is True
+ # Set the policy as administrator, then sign in twice through the normal local flow.
+ administrator = await client.post(
+ "/api/auth/login",
+ json={
+ "username": "admin",
+ "password": "a long test password",
+ },
+ )
+ client.headers["X-CSRF-Token"] = administrator.json()["csrf_token"]
+ saved = await client.put("/api/setup/onboarding-policy", json={"show_member_onboarding": False})
+ assert saved.status_code == 200
+ assert (await client.get("/api/auth/me")).json()["user"]["onboarding_required"] is True
+ for _ in range(2):
+ login = await client.post("/api/auth/login", json=login_body)
+ client.headers["X-CSRF-Token"] = login.json()["csrf_token"]
+ user = login.json()["user"]
+ assert user["onboarding_required"] == (role not in {"member", "viewer"})
+ assert user["onboarding_status"] == "pending"
+ for key in ("id", "permissions", "role", "access_label"):
+ assert user[key] == original[key]
+ assert (await client.get("/api/setup/onboarding")).json() == {
+ "status": "pending",
+ "step": 0,
+ "skipped": [],
+ }
+ # Optional setup remains writable, and completing it still persists normally.
+ progress = {"status": "completed", "step": 1, "skipped": [0]}
+ assert (await client.put("/api/setup/onboarding", json=progress)).json() == progress
+ signed_in = (await client.post("/api/auth/login", json=login_body)).json()["user"]
+ assert signed_in["onboarding_status"] == "completed"
+ assert signed_in["onboarding_required"] is False
+
+
+async def test_onboarding_policy_is_admin_only_and_requires_csrf(client, admin):
+ del client.headers["X-CSRF-Token"]
+ assert (
+ await client.put(
+ "/api/setup/onboarding-policy",
+ json={
+ "show_member_onboarding": False,
+ },
+ )
+ ).status_code == 403
+ logged = await client.post(
+ "/api/auth/login",
+ json={
+ "username": "admin",
+ "password": "a long test password",
+ },
+ )
+ client.headers["X-CSRF-Token"] = logged.json()["csrf_token"]
+ await client.post(
+ "/api/auth/users",
+ json={
+ "username": "member",
+ "display_name": "Member",
+ "role": "member",
+ "password": "a separate long password",
+ },
+ )
+ logged = await client.post(
+ "/api/auth/login",
+ json={
+ "username": "member",
+ "password": "a separate long password",
+ },
+ )
+ client.headers["X-CSRF-Token"] = logged.json()["csrf_token"]
+ assert (await client.get("/api/setup/onboarding-policy")).status_code == 403
+ assert (
+ await client.put(
+ "/api/setup/onboarding-policy",
+ json={
+ "show_member_onboarding": False,
+ },
+ )
+ ).status_code == 403
+ assert (await client.get("/api/auth/me")).json()["user"]["onboarding_required"] is True
+ client.cookies.clear()
+ assert (await client.get("/api/setup/onboarding-policy")).status_code == 401
+
+
+@pytest.mark.parametrize("body", [{"show_member_onboarding": "false"}, {"unexpected": False}])
+async def test_onboarding_policy_rejects_invalid_values(client, admin, body):
+ assert (await client.put("/api/setup/onboarding-policy", json=body)).status_code == 422
+
+
+async def test_custom_role_keeps_its_onboarding_policy(client, admin):
+ catalog = (await client.get("/api/auth/access")).json()
+ permissions = next(item["permissions"] for item in catalog["presets"] if item["id"] == "member")
+ role = await client.post(
+ "/api/auth/roles",
+ json={
+ "name": "Custom family role",
+ "permissions": permissions,
+ },
+ )
+ assert role.status_code == 201, role.text
+ created = await client.post(
+ "/api/auth/users",
+ json={
+ "username": "custom-reader",
+ "display_name": "Custom reader",
+ "role_id": role.json()["id"],
+ "password": "a separate long password",
+ },
+ )
+ assert created.status_code == 201, created.text
+ await client.put("/api/setup/onboarding-policy", json={"show_member_onboarding": False})
+ login = await client.post(
+ "/api/auth/login",
+ json={
+ "username": "custom-reader",
+ "password": "a separate long password",
+ },
+ )
+ assert login.json()["user"]["onboarding_required"] is True
+
+
+async def test_policy_preserves_library_grants_and_personal_catalog_accounts(
+ client, admin, database
+):
+ async with database() as db, db.begin():
+ integration = Integration(
+ kind="audiobookshelf",
+ name="Shared",
+ base_url="http://fixture.invalid",
+ encrypted_secrets="unused",
+ )
+ db.add(integration)
+ await db.flush()
+ granted = Library(integration_id=integration.id, external_id="granted", name="Family books")
+ private = Library(
+ integration_id=integration.id, external_id="private", name="Private books"
+ )
+ db.add_all([granted, private])
+ await db.flush()
+ granted_id = str(granted.id)
+ created = await client.post(
+ "/api/auth/users",
+ json={
+ "username": "family-reader",
+ "display_name": "Family reader",
+ "role": "member",
+ "password": "a separate long password",
+ "library_ids": [granted_id],
+ },
+ )
+ assert created.status_code == 201, created.text
+ reader_id = UUID(created.json()["id"])
+ saved = await client.put(
+ "/api/metadata/account", json={"enabled": True, "token": "admin-personal-test-token"}
+ )
+ assert saved.status_code == 200, saved.text
+ async with database() as db:
+ encrypted = (await db.get(CatalogAccount, UUID(admin["id"]))).encrypted_token
+ saved = await client.put("/api/setup/onboarding-policy", json={"show_member_onboarding": False})
+ assert saved.status_code == 200
+ login = await client.post(
+ "/api/auth/login",
+ json={"username": "family-reader", "password": "a separate long password"},
+ )
+ assert login.json()["user"]["onboarding_required"] is False
+ libraries = (await client.get("/api/library/libraries")).json()
+ assert [library["id"] for library in libraries] == [granted_id]
+ personal = (await client.get("/api/metadata/account")).json()
+ assert personal["enabled"] is False
+ async with database() as db:
+ assert await db.get(CatalogAccount, reader_id) is None
+ assert (await db.get(CatalogAccount, UUID(admin["id"]))).encrypted_token == encrypted
+ assert list(
+ await db.scalars(
+ select(LibraryGrant.library_id).where(LibraryGrant.user_id == reader_id)
+ )
+ ) == [UUID(granted_id)]
diff --git a/tests/unit/test_oidc.py b/tests/unit/test_oidc.py
index 7979ee47..dee1cfa1 100644
--- a/tests/unit/test_oidc.py
+++ b/tests/unit/test_oidc.py
@@ -11,11 +11,11 @@
authorization_url,
email_is_verified,
exact_username,
+ is_issuer_slash_correction,
merge_identity,
normalize_issuer,
role_from_groups,
same_origin,
- same_provider_host,
sanitize_username,
unverified,
)
@@ -38,7 +38,7 @@ def test_signature_changes_only_after_an_oidc_link():
def test_issuer_stays_on_one_origin():
issuer = normalize_issuer("http://id.example/application/o/dewarr/", scheme="http")
- assert issuer == "http://id.example/application/o/dewarr"
+ assert issuer == "http://id.example/application/o/dewarr/"
assert same_origin("http://id.example/application/o/dewarr/token", issuer)
for value in (
"https://id.example",
@@ -154,16 +154,19 @@ def test_userinfo_verification_stays_with_its_email():
)
-def test_provider_links_follow_the_socket():
- assert same_provider_host(
- "https://id.example/application/o/dewarr", "https://ID.Example:443/realms/dewarr"
- )
- assert not same_provider_host(
- "https://id.example/application/o/dewarr", "http://id.example:8443/realms/other"
- )
- assert not same_provider_host("https://id.example", "http://id.example")
- assert not same_provider_host("http://id.example", "http://other.example")
- assert not same_provider_host("", "http://id.example")
+def test_legacy_link_correction_is_limited_to_one_trailing_slash():
+ issuer = "https://id.example/application/o/dewarr"
+ assert is_issuer_slash_correction(issuer, issuer + "/")
+ assert is_issuer_slash_correction(issuer + "/", issuer)
+ assert not is_issuer_slash_correction(issuer + "/", issuer + "//")
+ for different in (
+ issuer,
+ "",
+ "https://id.example/other",
+ "http://id.example/application/o/dewarr/",
+ issuer + "//",
+ ):
+ assert not is_issuer_slash_correction(issuer, different)
class _Headers: