Skip to content

LDAP support#473

Merged
fccview merged 10 commits intofccview:developfrom
h-2:ldap_support2
Apr 9, 2026
Merged

LDAP support#473
fccview merged 10 commits intofccview:developfrom
h-2:ldap_support2

Conversation

@h-2
Copy link
Copy Markdown

@h-2 h-2 commented Mar 27, 2026

Adds LDAP support. Closes #453

  • Actual LDAP is provided by https://github.com/ldapts/ldapts
  • Almost all of the code was written by Claude.
  • I am not an expert in TS and don't know the jotty codebase well.
  • I have guided Claude closely and told it to re-use code as much as possible, but of course this still needs a proper review.
  • I have done my best to test this! It works for me™️

I know feelings are mixed about AI contributions, so I accept if this does not get priority.

The individual commits are separate steps and contain the AI instructions in their description, so I recommend reviewing commit-by-commit.

Since I needed to rebase, a bunch of manuel smaller changes were later cherry-picked into the commits, though.


supersedes #471 and #472

h-2 added 7 commits March 27, 2026 19:39
-----------

Step 1 — Extract `ensureUser()` to a shared location (non-semantic refactor)

**Why first**: This is a pure refactor with no behaviour change. Doing it before any new code avoids the new LDAP code also having to inline the function.

**What to do**:
- Move `ensureUser(username: string, isAdmin: boolean)` from `app/api/oidc/callback/route.ts` (lines 49–126) into `app/_server/actions/users/index.ts`.
- Export it from there.
- Replace the original inline function with an import. Verify the OIDC callback still compiles and the logic is identical.

**Acceptance criteria** — new test block in `tests/server-actions/users.test.ts`:

```ts
describe('ensureUser()', () => {
  it('creates the first user as both admin and superAdmin')
  it('creates a subsequent user with isAdmin=false when false is passed')
  it('creates a subsequent user with isAdmin=true when true is passed')
  it('promotes an existing non-admin user to admin when isAdmin=true')
  it('does NOT demote an existing admin user when isAdmin=false')
  it('does not create a duplicate user entry if called twice with the same username')
  it('creates the checklist and notes directories for the user')
})
```

All use the mocked `fs/promises` and `proper-lockfile` already in `tests/setup.ts`. No new mocks needed.

------------

Step 1 complete. Summary of changes:

- **`app/_server/actions/users/index.ts`** — added `import { lock, unlock } from "proper-lockfile"` and exported the new `ensureUser()` function
- **`app/api/oidc/callback/route.ts`** — removed the inline `ensureUser` definition and the now-unused `CHECKLISTS_FOLDER`/`NOTES_FOLDER` imports; added `import { ensureUser } from "@/app/_server/actions/users"`
- **`tests/server-actions/users.test.ts`** — added 7 tests covering all acceptance criteria from the plan

549 tests passing, no regressions.
--------

Step 2 — Install `ldapts` and register its mock ✅ COMPLETED

**What to do**:
- `yarn add ldapts`
- Add a `vi.mock('ldapts', ...)` block to `tests/setup.ts` so every subsequent test file gets a clean, controllable LDAP client. The mock should expose `bind`, `search`, and `unbind` as `vi.fn()` on a mock `Client` class.

```ts
// tests/setup.ts addition
vi.mock('ldapts', () => ({
  Client: vi.fn().mockImplementation(() => ({
    bind: vi.fn().mockResolvedValue(undefined),
    search: vi.fn().mockResolvedValue({ searchEntries: [] }),
    unbind: vi.fn().mockResolvedValue(undefined),
  })),
}))
```

**Acceptance criteria**: `yarn build` (or `tsc --noEmit`) succeeds with an `import { Client } from 'ldapts'` somewhere in the project. No runtime test needed for this step.

---------

Done. [email protected] installed (pure JS, no native compilation), mock registered in tests/setup.ts, TypeScript types resolve cleanly, all tests still green.
-----

Step 3 — Create `ldapLogin()` in `app/_server/actions/auth/ldap.ts` ✅ COMPLETED

This is the core of the LDAP implementation. It is a standalone async function with no dependency on Next.js request/response plumbing, making it the most testable piece.

**What to do**: Create `app/_server/actions/auth/ldap.ts` implementing the flow from the plan:
1. Bind as service account.
2. Search for the user by `LDAP_USER_ATTRIBUTE` (default: `uid`).
3. Bind as the found user's DN to verify their password.
4. Read the `memberOf` attribute to resolve group memberships.
5. Apply `LDAP_USER_GROUPS` and `LDAP_ADMIN_GROUPS` checks.
6. Return a typed result (see below), never throw to the caller.

Use `getEnvOrFile('LDAP_BIND_PASSWORD', 'LDAP_BIND_PASSWORD_FILE')` for the service account password.

**Return type** (distinguishes error kinds so the caller can decide on brute-force counting):

```ts
type LdapLoginResult =
  | { ok: true; username: string; isAdmin: boolean }
  | { ok: false; kind: 'invalid_credentials' }   // wrong password or user not found
  | { ok: false; kind: 'unauthorized' }           // user exists but not in LDAP_USER_GROUPS
  | { ok: false; kind: 'connection_error' }       // server unreachable or misconfigured
```

**Acceptance criteria** — new test file `tests/server-actions/ldap.test.ts`:

```ts
// Each test overrides the mock returned by the Client constructor
// (bind, search, unbind) via mockImplementation or mockRejectedValueOnce.

describe('ldapLogin()', () => {
  // Core credential checks
  it('returns invalid_credentials when user is not found in the directory')
  it('returns invalid_credentials when the user bind fails (wrong password)')
  it('returns { ok: true } on successful bind with no group restrictions configured')

  // Connection errors — must NOT appear as invalid_credentials
  it('returns connection_error when the service account bind throws a network error')
  it('returns connection_error when the search throws (e.g. timeout)')

  // Group-based access control
  it('returns unauthorized when LDAP_USER_GROUPS is set and user has no matching memberOf')
  it('returns { ok: true } when user is in LDAP_USER_GROUPS')
  it('returns { ok: true } with isAdmin=false when user is in LDAP_USER_GROUPS but not LDAP_ADMIN_GROUPS')
  it('returns { ok: true } with isAdmin=true when user memberOf matches LDAP_ADMIN_GROUPS')
  it('returns { ok: true } for an admin user even when LDAP_USER_GROUPS is set and they are not in it')

  // Config
  it('uses uid as the default search attribute when LDAP_USER_ATTRIBUTE is not set')
  it('uses LDAP_USER_ATTRIBUTE when set (e.g. sAMAccountName)')

  // Cleanup
  it('calls unbind after a successful authentication')
  it('calls unbind even when the user bind fails')
})
```

-----

Done. Two issues caught and fixed during implementation:

1. **Arrow functions as mock constructors** — Vitest 4 calls `new implementation(...)` internally, which throws for arrow functions. Fixed by changing both `tests/setup.ts` and `setupClient()` to use regular `function()` syntax.

2. **Comma separator incompatible with LDAP DNs** — LDAP DNs contain commas (e.g. `cn=admins,dc=example,dc=com`), so splitting `LDAP_ADMIN_GROUPS`/`LDAP_USER_GROUPS` by `,` would shred them into fragments. Changed separator to `|` and updated `LDAP_PLAN.md` to document this.
------

Step 4 — Wire `ldapLogin` into the `login` server action ✅ COMPLETED

**What to do**: In `app/_server/actions/auth/index.ts`, add a branch at the top of `login()`:

```ts
if (process.env.SSO_MODE === 'ldap') {
  // delegate to ldapLogin, then handle result:
  // - connection_error  → return generic error, do NOT increment brute-force counter
  // - invalid_credentials → return 'Invalid username or password', DO increment counter
  // - unauthorized      → redirect to /auth/login?error=unauthorized
  // - ok                → ensureUser(), createSession(..., 'ldap'), set cookie, redirect /
}
```

The brute-force lock check that already exists at the top of `login()` runs before this branch and applies naturally — no changes needed there.

**Acceptance criteria** — new `describe` block in `tests/server-actions/auth.test.ts`:

Mock `ldapLogin` (vi.mock `@/app/_server/actions/auth/ldap`) and `ensureUser` alongside the existing mocks.

```ts
describe('login() with SSO_MODE=ldap', () => {
  it('delegates to ldapLogin instead of checking the local password hash')

  it('returns an error and does NOT call createSession on invalid_credentials')
  it('increments the brute-force counter on invalid_credentials')

  it('returns a generic error and does NOT increment the brute-force counter on connection_error')

  it('redirects to /auth/login?error=unauthorized on unauthorized result')

  it('calls ensureUser with the username and isAdmin flag on success')
  it('calls createSession with loginType "ldap" on success')
  it('sets the session cookie on success')
  it('redirects to / on success')

  it('still applies the brute-force lockout check before contacting LDAP')
})
```

After this step, the full LDAP authentication flow is functional and testable end-to-end at the unit level.

-------

Step 4 is done. All 573 tests pass.

**What was implemented:**

- `app/_server/actions/auth/index.ts`: Added LDAP branch in `login()` that:
  - Runs after the existing brute-force check (which still applies)
  - Calls `ldapLogin(username, password)`
  - `connection_error` → generic "Authentication service unavailable" error, no brute-force increment
  - `invalid_credentials` → increments brute-force counter (if user exists locally), returns "Invalid username or password"
  - `unauthorized` → releases lock then redirects to `/auth/login?error=unauthorized`
  - Success → releases lock, calls `ensureUser()` (lock released first to avoid deadlock), creates session with type `"ldap"`, sets cookie, redirects to `/`

- `tests/server-actions/auth.test.ts`: Added 10-test `describe('login() with SSO_MODE=ldap', ...)` block covering all acceptance criteria from the plan.

Next up are Steps 5 and 6 (login and setup page fixes for LDAP mode — manually testable only). Ready when you are.
----

Step 5 — Fix the login page and setup page for LDAP mode ⚠️ POORLY TESTABLE ✅ IMPLEMENTED

> The login page is a Next.js server component. The project has no component or E2E tests, so this step cannot be covered by the Vitest suite. Manual verification is the only option under the current project design.

*I scrapped Claude's recommendations and "implemented" this myself, because clearer. See the diff of the commit!*

Intended bahaviour for login-page when there are no users:

| `SSO_MODE` | `SSO_FALLBACK_LOCAL` | → Redirect to setup |
|---|---|---|
| unset | no | ✅ yes (needs first user) |
| `oidc` | no | no (OIDC provisions users) |
| `oidc` | yes | ✅ yes (fallback admin) |
| `ldap` | no | no (LDAP provisions users) |
| `ldap` | yes | ✅ yes (fallback admin) |

All of the above were tested and behave as expected.

Intended bahaviour for setup-page when there are no users (invert of above):

| `SSO_MODE` | `SSO_FALLBACK_LOCAL` | → Redirect to login |
|---|---|---|
| unset | no | no  |
| `oidc` | no | yes |
| `oidc` | yes | no |
| `ldap` | no | yes |
| `ldap` | yes | no |

All of the above were tested and behave as expected.
export default async function LoginPage() {
const t = await getTranslations("auth");
const ssoEnabled = process.env.SSO_MODE === "oidc";
const ssoIsOidc = process.env.SSO_MODE === "oidc";
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed this for clarity


const hasExistingUsers = await hasUsers();
if ((!hasExistingUsers && !ssoEnabled) || (!hasExistingUsers && allowLocal)) {
if (!hasExistingUsers && (!process.env.SSO_MODE || allowLocal)) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here means "any SSO_MODE" (including LDAP). I don't know if you prefer some central variable over this env check.


/**
* 🧙‍♂️
* ‍♂️
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wizard must be put back, dunno why your linter decided it couldn't be rendered. I'll fix it when I pull the PR 😆

You may or may not have noticed a bunch of troll comments around the codebase, I get bored and have to leave stupid stuff around. Try searching the code for @fccview here 😆

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will restore Gandalf!


const usersFile = path.join(process.cwd(), "data", "users", "users.json");
await lock(usersFile);
// fccview is onto you!
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHECKMATE! 😆

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall I remove the comment?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll sort it out when i finish this up ♥️

}

// invalid_credentials — increment brute-force counter if user exists locally
if (user && !bruteforceProtectionDisabled) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is only bruteforce protection for local users, because we don't write to the ldap.

howto/LDAP.md Outdated

### Required

- `SSO_MODE=ldap` — Enables LDAP authentication. The standard username/password form is used; the OIDC "Sign in with SSO" button is not shown.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reused the environment variable, because it was the obvious thing to do, but of course LDAP is not "SSO" strictly speaking, so some general term might have been better.

const nextConfig = {
output: "standalone",
serverExternalPackages: ["ws"],
serverExternalPackages: ["ws", "libsodium-wrappers-sumo"],
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is apparently neccessary on Linux/arm.

"dompurify": "^3.3.0",
"hast-util-to-html": "^9.0.5",
"hugeicons-react": "^0.3.0",
"hugeicons-react": "0.3.0",
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This took me super-long to debug. ^0.3.0 results in 0.3.1 (if not using frozen lockfile), but 0.3.1 is broken and contains no content. 0.4.0 is not broken, but also not compatible 🤯

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bloody dependencies 🤦

"js-beautify": "^1.15.4",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"ldapts": "^8.1.7",
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the dependency that actually does the LDAP

"lodash-es": "^4.17.23",
"lodash": "^4.17.23",
"nanoid": "^3.3.8",
"**/mermaid": "^11.12.1",
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yarn-4.1 removed this on first start

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look into it, may not be necessary anymore


- Changing a user's password or deleting a user in jotty's admin panel does **not** propagate to the LDAP server.
- Changing a password in jotty's personal settings has no effect on LDAP login — the user always authenticates against LDAP.
- Deleting a user in jotty removes their local notes and checklists, but does not remove them from the directory.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the above three points are actually sub-optimal UX, but I didn't want to change this, since it's this way for OIDC, too. And I wanted to keep this PR minimal.

If you like, I could prepare another PR at some point which hides the respective options during sessions that are not local.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can play with this afterwards, that behaviour always bothered me anyway, but let's get this ready to be merged first

@fccview
Copy link
Copy Markdown
Owner

fccview commented Mar 28, 2026

Loved the comments, thank you for going through the diffs before I get a chance to do so myself.
I will review this as soon as I am back home as working on jotty from here is a pain in the ass not gonna lie, but at a first glance it all makes sense.

I agree the naming of SSO_MODE is not great, we may need to change that to AUTH_MODE for future users and leave a fallback to SSO_MODE for legacy, should be easy enough to achieve, we remove SSO_MODE from the documentation and add a @deprecated comment above the old env variable, eventually removing it safely.

I will need to set up LDAP and play around with it all, but this is GREAT stuff overall, thank you so much!

@h-2
Copy link
Copy Markdown
Author

h-2 commented Mar 29, 2026

Loved the comments, thank you for going through the diffs before I get a chance to do so myself.
I will review this as soon as I am back home as working on jotty from here is a pain in the ass not gonna lie, but at a first glance it all makes sense.

Glad you appreciate it, and no rush of course! I just had a day off and wanted to see whether I could contribute something :)

I agree the naming of SSO_MODE is not great, we may need to change that to AUTH_MODE for future users and leave a fallback to SSO_MODE for legacy, should be easy enough to achieve, we remove SSO_MODE from the documentation and add a @deprecated comment above the old env variable, eventually removing it safely.

👍🏻

I will need to set up LDAP and play around with it all

I recommend https://github.com/lldap/lldap , it's a lot simpler than most other LDAP servers!

but this is GREAT stuff overall, thank you so much!

Thank you for publishing this useful service and good luck with the family situation 🍀

@fccview
Copy link
Copy Markdown
Owner

fccview commented Apr 8, 2026

Alright, tomorrow I'll be merging #461 , been working on it all day today!
Once done with that I'll jump on this pull request and sort it out.

I finally have a bit more time ❤️

@fccview
Copy link
Copy Markdown
Owner

fccview commented Apr 9, 2026

This is AWESOME!
Did a bit of cleanup, centralised functions to avoid repetitions and done a ton of testing (thank you for the tip on how to set it up).

Ldap is pretty damn cool, not gonna lie, and this implementation does make a lot of sense, thank you for taking the time to get the ball rolling. Merging!

@fccview fccview merged commit 1766a61 into fccview:develop Apr 9, 2026
@h-2
Copy link
Copy Markdown
Author

h-2 commented Apr 10, 2026

Thank you for taking the time to merge this; I am glad it's useful!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants