Compare commits

..
8 Commits
Author SHA1 Message Date
github-actions[bot] 9c2028e187 chore: bump version to 0.10.1 2026-03-09 10:48:47 +00:00
Frederic CabassutandClaude Opus 4.6 cb9857324a fix: migrate password_hash column to nullable for OAuth-only users
Existing databases created from the original migration had password_hash
as NOT NULL. This adds an auto-migration that recreates the users table
with a nullable password_hash column, enabling OAuth-only user creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:47:48 +01:00
Frederic CabassutandClaude Opus 4.6 e90c9c041c docs: use Authentik OIDC example in README instead of Google OAuth
Self-hosted auth provider is more relevant for a self-hosted app.
Also adds AUTH.md to the documentation table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:42:25 +01:00
github-actions[bot] d9b3bd9ff6 chore: pin docker-compose to v0.10.0 [skip ci] 2026-03-09 10:42:14 +00:00
github-actions[bot] 8ec278c49a chore: bump version to 0.10.0 2026-03-09 10:37:11 +00:00
Frederic CabassutandClaude Opus 4.6 4a280b5932 feat: allow creating OAuth-only users without a password
Admin can now toggle "OAuth-only (no password)" when creating a user.
These users have no password hash and authProvider set to 'none', so
they can only log in via an OAuth/SSO provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:12 +01:00
Frederic CabassutandClaude Opus 4.6 7297dc0616 docs: add dedicated AUTH.md and deduplicate auth info across docs
Create AUTH.md as the canonical reference for authentication: password
auth, OAuth/SSO setup, provider-specific guides (Authentik, Keycloak,
Okta/Auth0), env vars, callback URLs, security notes, and
troubleshooting. Fixes incorrect env var names and callback URL patterns
that had drifted in DEPLOYMENT.md. Other docs now cross-link to AUTH.md
instead of duplicating content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:12 +01:00
github-actions[bot] 4e2e0353df chore: pin docker-compose to v0.9.1 [skip ci] 2026-03-09 10:04:53 +00:00
13 changed files with 287 additions and 103 deletions
+1
View File
@@ -215,5 +215,6 @@ Keep docs in `docs/` up to date when making changes. If a feature, API endpoint,
|-----|--------|
| `docs/FEATURES.md` | User-facing feature list |
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
+8 -3
View File
@@ -21,7 +21,7 @@ docker compose up -d
Open http://localhost:3000 and create your admin account on first visit.
#### With OAuth (optional)
#### With SSO (optional)
```yaml
# docker-compose.yml
@@ -34,14 +34,18 @@ services:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=your-google-client-id
- AUTH_GOOGLE_SECRET=your-google-client-secret
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=your-client-id
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
### Manual (Node.js)
```bash
@@ -99,6 +103,7 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|----------|-------------|
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.9.0
image: ghcr.io/bretzel-app/crumbs:0.10.0
container_name: crumbs
ports:
- "3000:3000"
+2 -3
View File
@@ -83,9 +83,8 @@ For a single-user app with multiple devices:
- New v4 uses Vite plugin (faster builds)
### Argon2
- Current best practice for password hashing
- Memory-hard (resists GPU attacks)
- Winner of the Password Hashing Competition
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
## Database Schema
+209
View File
@@ -0,0 +1,209 @@
# Authentication
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
## First-run setup
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
## Password authentication
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can reset passwords and revoke sessions for any user via Settings
## OAuth / SSO
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
### How it works
1. User clicks an OAuth button on the login page
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
3. Provider authenticates the user and redirects back to Crumbs
4. Crumbs validates the response, extracts the user's email and name
5. If a matching user exists (by provider ID or email), a session is created
6. If no matching user exists, the login is rejected (invite-only model)
### Invite-only model
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users > Invite User**
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
### Environment variables
#### Google OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
Setup:
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create an OAuth 2.0 Client ID (Web application)
3. Add the callback URL above as an authorized redirect URI
4. Copy the client ID and secret into your environment
#### GitHub OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
Setup:
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set the authorization callback URL to the callback URL above
4. Copy the client ID and secret into your environment
#### Generic OIDC
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
| Variable | Description |
|----------|-------------|
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
**Required scopes:** `openid`, `profile`, `email`
### Provider guides
#### Authentik
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
**1. Create a provider in Authentik:**
1. Open the Authentik admin panel
2. Go to **Applications > Providers > Create**
3. Select **OAuth2/OpenID Connect**
4. Configure:
- **Name:** `Crumbs`
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
- **Client type:** `Confidential`
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
- **Scopes:** `openid`, `profile`, `email`
5. Save and note the **Client ID** and **Client Secret**
**2. Create an application in Authentik:**
1. Go to **Applications > Applications > Create**
2. Configure:
- **Name:** `Crumbs`
- **Slug:** `crumbs`
- **Provider:** Select the `Crumbs` provider you just created
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
3. Save
**3. Configure Crumbs:**
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
environment:
- ORIGIN=https://notes.example.com
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=<client-id>
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
- AUTH_OIDC_DISPLAY_NAME=Authentik
```
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
**4. Pre-create users:**
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
#### Keycloak
1. Create a new client in your Keycloak realm
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
```env
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
AUTH_OIDC_CLIENT_ID=crumbs
AUTH_OIDC_CLIENT_SECRET=<client-secret>
AUTH_OIDC_DISPLAY_NAME=Keycloak
```
#### Okta / Auth0
Use the issuer URL from your Okta org or Auth0 tenant:
```env
# Okta
AUTH_OIDC_ISSUER=https://your-org.okta.com
# Auth0
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
```
### Docker example with multiple providers
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
# Google
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
# GitHub
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
# Authentik (OIDC)
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
All configured providers will appear as buttons on the login page.
## Security notes
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
- Passwords are hashed with **Argon2** (never stored in plain text)
- Sessions expire after **30 days**
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
- Always use **HTTPS** in production (via reverse proxy)
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
+4 -62
View File
@@ -110,48 +110,9 @@ node build
| `PORT` | `3000` | HTTP port |
| `NODE_ENV` | `development` | Set to `production` for deployment |
### OAuth Providers (optional)
### OAuth / SSO (optional)
Enable social login by setting the client ID and secret for any provider. Users who sign in via OAuth are created automatically (invite-only when combined with admin approval).
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_ID` | Google OAuth client ID |
| `AUTH_GOOGLE_SECRET` | Google OAuth client secret |
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
| `AUTH_OIDC_ISSUER` | Generic OIDC issuer URL (e.g. `https://accounts.example.com`) |
| `AUTH_OIDC_ID` | OIDC client ID |
| `AUTH_OIDC_SECRET` | OIDC client secret |
| `AUTH_OIDC_NAME` | Display name for the OIDC button (default: "SSO") |
**Callback URLs** — register these in your provider's dashboard:
| Provider | Callback URL |
|----------|-------------|
| Google | `https://your-domain/api/auth/callback/google` |
| GitHub | `https://your-domain/api/auth/callback/github` |
| OIDC | `https://your-domain/api/auth/callback/oidc` |
#### Docker example with Google OAuth
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_SECRET=GOCSPX-xxxxxxxxxxxxx
restart: unless-stopped
volumes:
crumbs-data:
```
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
## Health Check
@@ -163,7 +124,7 @@ docker inspect --format='{{.State.Health.Status}}' crumbs
## MCP Server Configuration
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes.
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
### Setup
@@ -186,28 +147,9 @@ Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI as
}
```
### Available Tools
| Tool | Description |
|------|-------------|
| `list_notes` | List notes (filter by status/tag) |
| `get_note` | Get a note by ID |
| `create_note` | Create a new note |
| `update_note` | Update a note |
| `trash_note` / `restore_note` | Move to/from trash |
| `archive_note` / `unarchive_note` | Archive/unarchive |
| `delete_note` | Permanently delete |
| `search_notes` | Full-text search |
| `list_tags` | List all tags |
| `pin_note` | Pin/unpin a note |
| `reorder_notes` | Set sort orders |
| `upload_image` | Attach image from URL |
## Security Notes
- Always use HTTPS in production (via reverse proxy)
- Set a strong password (min 8 characters recommended)
- The `ORIGIN` variable must match your actual domain for CSRF protection
- Sessions expire after 30 days
- Passwords are hashed with Argon2
- API keys are SHA-256 hashed (never stored in plain text)
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
+6 -14
View File
@@ -86,20 +86,12 @@
- Clear search to restore original view
- Case-insensitive matching
### Authentication
- Multi-user support with role-based access (admin / user)
- First-run setup: create admin account (min 8 chars password)
- Argon2 password hashing (industry standard)
- Session-based auth with 30-day expiry
- Active session management (view, revoke individual or all other sessions)
- All routes protected except /login and /setup
- Account deletion with password confirmation
### OAuth / SSO
- Optional social login via Google, GitHub, or generic OIDC
- OAuth users created automatically on first sign-in
- Invite-only mode when combined with admin controls
- Configure via environment variables (`AUTH_GOOGLE_ID`, `AUTH_GITHUB_ID`, `AUTH_OIDC_ISSUER`, etc.)
### Authentication & OAuth / SSO
- Multi-user with role-based access (admin / user)
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
- Session-based auth with active session management
- Invite-only OAuth model with PKCE-secured flows
- See [AUTH.md](AUTH.md) for full details and provider setup guides
### User Management (admin)
- Admin dashboard to create, list, and delete users
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.9.1",
"version": "0.10.1",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
+3 -3
View File
@@ -194,10 +194,10 @@ export function revokeAllSessions(userId: number, exceptToken?: string): void {
export async function createUser(
email: string,
displayName: string,
password: string,
password: string | null,
role: 'admin' | 'user' = 'user'
): Promise<User> {
const hash = await argon2.hash(password);
const hash = password ? await argon2.hash(password) : null;
const result = db
.insert(users)
.values({
@@ -205,7 +205,7 @@ export async function createUser(
displayName,
role,
passwordHash: hash,
authProvider: 'password',
authProvider: password ? 'password' : 'none',
createdAt: new Date()
})
.returning()
+23
View File
@@ -201,6 +201,29 @@ try {
// Column already exists
}
// Migration: make password_hash nullable (for OAuth-only users)
const pwCol = (
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
).find((col) => col.name === 'password_hash');
if (pwCol && pwCol.notnull) {
sqlite.exec(`
CREATE TABLE __new_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
+24 -11
View File
@@ -11,17 +11,23 @@
let newDisplayName = $state('');
let newPassword = $state('');
let newRole = $state<'admin' | 'user'>('user');
let oauthOnly = $state(false);
let createMsg = $state('');
let createError = $state(false);
async function createUser() {
createMsg = '';
if (!newEmail || !newPassword) {
createMsg = 'Email and password are required';
if (!newEmail) {
createMsg = 'Email is required';
createError = true;
return;
}
if (newPassword.length < 8) {
if (!oauthOnly && !newPassword) {
createMsg = 'Password is required (or enable OAuth-only)';
createError = true;
return;
}
if (!oauthOnly && newPassword.length < 8) {
createMsg = 'Password must be at least 8 characters';
createError = true;
return;
@@ -33,7 +39,7 @@
body: JSON.stringify({
email: newEmail,
displayName: newDisplayName || newEmail.split('@')[0],
password: newPassword,
password: oauthOnly ? undefined : newPassword,
role: newRole
})
});
@@ -44,6 +50,7 @@
newDisplayName = '';
newPassword = '';
newRole = 'user';
oauthOnly = false;
createMsg = 'User created';
createError = false;
} else {
@@ -125,13 +132,19 @@
placeholder="Display name (optional)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<PasswordStrengthMeter password={newPassword} />
<label class="flex items-center gap-2 text-sm text-[var(--text)]">
<input type="checkbox" bind:checked={oauthOnly} />
OAuth-only (no password)
</label>
{#if !oauthOnly}
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<PasswordStrengthMeter password={newPassword} />
{/if}
<select
bind:value={newRole}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
+4 -4
View File
@@ -12,14 +12,14 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
requireAdmin(event);
const { email, displayName, password, role } = await request.json();
if (!email || !password) {
throw error(400, 'Email and password are required');
if (!email) {
throw error(400, 'Email is required');
}
if (password.length < 8) {
if (password && password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const user = await createUser(email, displayName || email.split('@')[0], password, role || 'user');
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
return json(user, { status: 201 });
};
+1 -1
View File
@@ -249,7 +249,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.9.1</span>
<span class="footer-version">v0.10.1</span>
</div>
</footer>