After first-user setup is complete, Bloud should require authentication to access. Users authenticate via Authentik (OIDC), and Bloud validates their session before allowing access.
- First-user setup creates a user in Authentik and a local record
- No login enforcement - anyone can access Bloud after setup
- Authentik is already running and configured for app SSO
- Protect Bloud UI and API behind authentication
- Use Authentik as the identity provider (OIDC)
- Simple session management - no complex role/permission system yet
Option A: Forward Auth via Traefik
Browser → Traefik → Authentik Forward Auth → Bloud
- Pros: Already working for other apps, no code changes, session handled by Authentik
- Cons: Less control, can't have unauthenticated endpoints, adds latency
Option B: OIDC Integration in Bloud
Browser → Bloud → (redirect to Authentik) → Bloud validates token
- Pros: Full control over routes, mix auth/unauth endpoints, better UX
- Cons: More code, need to handle token refresh
Option C: Hybrid - Forward Auth + API Tokens
- Pros: Simple UI auth, supports API access
- Cons: Two auth mechanisms to maintain
Forward auth won't work well because:
/api/setup/statusmust be accessible without auth- We want to show a login page, not redirect immediately
- Future features (user preferences, API tokens) need user context in the backend
Goal: Users can log in via Authentik and access protected routes.
During Authentik's DynamicConfig phase, create an OAuth2 provider and application for Bloud itself:
// In authentik configurator
func (c *Configurator) ensureBloudOAuthApp() error {
// Create OAuth2 provider for Bloud
// Client ID: "bloud"
// Redirect URI: http://localhost:8080/auth/callback
// Scopes: openid, profile, email
}GET /auth/login → Redirect to Authentik authorization URL
GET /auth/callback → Handle OAuth callback, create session
POST /auth/logout → Clear session, redirect to Authentik logout
GET /api/auth/me → Return current user info (or 401)
Store sessions server-side in PostgreSQL:
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- Random session ID
user_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);Session ID stored in HTTP-only cookie.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := s.getSessionFromCookie(r)
if session == nil || session.IsExpired() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userContextKey, session.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}s.router.Route("/api", func(r chi.Router) {
// Public routes (no auth required)
r.Get("/health", s.handleHealth)
r.Route("/setup", func(r chi.Router) {
r.Get("/status", s.handleSetupStatus)
r.Post("/create-user", s.handleCreateUser)
})
// Auth routes (handle login/logout)
r.Route("/auth", func(r chi.Router) {
r.Get("/login", s.handleLogin)
r.Get("/callback", s.handleOAuthCallback)
r.Post("/logout", s.handleLogout)
r.Get("/me", s.handleGetCurrentUser)
})
// Protected routes (require auth)
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Route("/apps", func(r chi.Router) { /* ... */ })
r.Route("/system", func(r chi.Router) { /* ... */ })
})
})Unit Tests:
TestAuthMiddleware_NoSession- Returns 401 without sessionTestAuthMiddleware_ValidSession- Returns 200 with valid sessionTestAuthMiddleware_ExpiredSession- Returns 401 with expired session
Integration Tests:
TestOIDCFlow_Login-/auth/loginredirects to AuthentikTestOIDCFlow_Callback- Callback creates session and redirects
Manual Verification:
- Navigate to protected route without session → 401
-
/auth/loginredirects to Authentik - After Authentik login, redirected back with session cookie
- Protected routes now accessible
-
OIDC Library Choice: Currently implementing OIDC manually. Consider using
github.com/coreos/go-oidcfor better standards compliance and less maintenance burden. -
Session Storage: In-memory sessions would be faster for single-node deployments. Current PostgreSQL approach is correct for durability but adds latency. Could add Redis caching layer later.
-
State Parameter Handling: OIDC state should be stored server-side with expiry to prevent CSRF. Currently using simple random string - could add cryptographic binding to session.
Goal: Frontend shows login page when unauthenticated and handles auth state.
// lib/stores/auth.ts
interface AuthState {
user: User | null;
loading: boolean;
}
export const auth = writable<AuthState>({ user: null, loading: true });
export async function checkAuth(): Promise<boolean> {
const res = await fetch('/api/auth/me');
if (res.ok) {
const user = await res.json();
auth.set({ user, loading: false });
return true;
}
auth.set({ user: null, loading: false });
return false;
}<!-- +layout.svelte -->
{#if loading}
<LoadingSpinner />
{:else if setupRequired}
<SetupWizard />
{:else if !$auth.user}
<LoginPage />
{:else}
<App />
{/if}Simple page with "Sign in with Authentik" button that redirects to /auth/login.
Manual Verification:
- Fresh page load shows loading spinner briefly
- Unauthenticated user sees login page
- "Sign in" button redirects to Authentik
- After login, dashboard loads
- Refresh maintains auth state
-
Auth State Persistence: Currently re-checking
/api/auth/meon every page load. Could cache auth state in localStorage with short TTL to reduce API calls. -
Loading States: Single loading boolean. Consider a state machine (
idle | checking | authenticated | unauthenticated) for clearer state management. -
Error Handling: No handling for network failures during auth check. Should show error state with retry option.
Goal: Handle token refresh and session expiry gracefully.
OIDC access tokens expire (typically 5-60 minutes). Options:
- Short sessions, re-login - Simple but annoying
- Refresh tokens - Store refresh token, use to get new access token
- Long session cookie - Session lasts longer than access token, re-validate periodically
Decision: Option 3 - Session cookie lasts 7 days, periodic Authentik validation.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := s.getSessionFromCookie(r)
if session == nil || session.IsExpired() {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Re-validate with Authentik if last check was > 1 hour ago
if session.NeedsRevalidation() {
if !s.authentikClient.ValidateUser(session.UserID) {
s.sessionStore.Delete(session.ID)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
s.sessionStore.UpdateLastValidated(session.ID)
}
ctx := context.WithValue(r.Context(), userContextKey, session.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Background job to delete expired sessions:
func (s *Server) cleanupExpiredSessions() {
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
s.sessionStore.DeleteExpired()
}
}Unit Tests:
TestSessionStore_ExpiredSession- Expired sessions return nilTestSessionStore_DeleteExpired- Cleanup removes old sessions
Manual Verification:
- Session persists across browser restarts
- After 7 days, session expires and login required
- Deactivating user in Authentik invalidates session within 1 hour
-
Refresh Token Flow: Current periodic validation requires Authentik to be available. Proper refresh token flow would be more resilient.
-
Session Revocation: No real-time session revocation when user is deactivated. Webhook from Authentik could enable immediate invalidation.
-
Cleanup Job: Simple ticker-based cleanup. Could use PostgreSQL's
pg_cronor a proper job scheduler for more reliability.
Goal: Maintain local user records for session association and user management.
Bloud maintains a local users table as a lightweight reference to users whose credentials are stored in Authentik. This separation ensures:
- Single source of truth for credentials: Authentik manages passwords, MFA, and authentication
- Local user context: Bloud can associate data (preferences, sessions) with users without querying Authentik
- Fast lookups: Session validation doesn't require Authentik API calls
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Located in internal/store/users.go:
type UserStore struct {
db *sql.DB
}
// HasUsers checks if any users exist (used for first-user setup detection)
func (s *UserStore) HasUsers() (bool, error)
// Create adds a new user to the database
func (s *UserStore) Create(username string) error
// GetByUsername returns a user by username
func (s *UserStore) GetByUsername(username string) (*User, error)
// GetByID returns a user by ID
func (s *UserStore) GetByID(id int) (*User, error)
// List returns all users
func (s *UserStore) List() ([]*User, error)
// Delete removes a user by ID
func (s *UserStore) Delete(id int) errorThe local users table may become out of sync with Authentik if users are created/deleted directly in Authentik.
Strategy: On-demand sync - When user logs in via OIDC but has no local record, create one:
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
// ... validate token, get user info from Authentik ...
user, err := s.userStore.GetByUsername(userInfo.Username)
if err != nil {
// Handle error
}
if user == nil {
// User authenticated but no local record - create one
if err := s.userStore.Create(userInfo.Username); err != nil {
// Log but don't fail - user can still use the system
}
user, _ = s.userStore.GetByUsername(userInfo.Username)
}
// Create session with user
}Unit Tests:
func TestUserStore_HasUsers(t *testing.T) {
db := setupTestDB(t)
store := NewUserStore(db)
has, err := store.HasUsers()
require.NoError(t, err)
assert.False(t, has)
err = store.Create("testuser")
require.NoError(t, err)
has, err = store.HasUsers()
require.NoError(t, err)
assert.True(t, has)
}
func TestUserStore_Create(t *testing.T) {
db := setupTestDB(t)
store := NewUserStore(db)
err := store.Create("testuser")
require.NoError(t, err)
// Duplicate username should fail
err = store.Create("testuser")
assert.Error(t, err)
}
func TestUserStore_GetByUsername(t *testing.T) {
db := setupTestDB(t)
store := NewUserStore(db)
user, err := store.GetByUsername("nonexistent")
require.NoError(t, err)
assert.Nil(t, user)
store.Create("testuser")
user, err = store.GetByUsername("testuser")
require.NoError(t, err)
assert.Equal(t, "testuser", user.Username)
}-
Authentik User ID: Currently only storing username. Should store Authentik's user ID (
pk) for more reliable sync and to handle username changes. -
Email Field: No email stored locally. Would be needed for notifications feature.
-
Soft Delete: Currently hard-deleting users. Soft delete with
deleted_attimestamp would preserve audit trail and allow recovery. -
User Metadata: No way to store additional user preferences locally. Consider JSON
metadatacolumn.
Goal: Admins can add and remove users through the Bloud UI.
┌─────────────────────────────────────────────────────────────────────┐
│ Add User Flow │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Admin clicks "Add User" in settings │
│ │
│ 2. Admin enters: username, password │
│ │
│ 3. Frontend: POST /api/users │
│ { │
│ "username": "newuser", │
│ "password": "userpassword" │
│ } │
│ │
│ 4. Backend: │
│ a. Validate admin is authenticated │
│ b. Create user in Authentik via API │
│ c. Create local user record │
│ d. Return success │
│ │
│ 5. New user can now log in via Authentik OIDC │
│ │
└─────────────────────────────────────────────────────────────────────┘
Create a new user. Requires authentication.
Request:
{
"username": "string (3-30 chars, alphanumeric + underscore)",
"password": "string (min 8 chars)"
}Response (success):
{
"id": 2,
"username": "newuser",
"created_at": "2024-01-15T10:30:00Z"
}Error cases:
- 401 Unauthorized - Not authenticated
- 400 Bad Request - Invalid input or username already exists
- 503 Service Unavailable - Authentik not available
List all users. Requires authentication.
Response:
{
"users": [
{ "id": 1, "username": "admin", "created_at": "2024-01-01T00:00:00Z" },
{ "id": 2, "username": "newuser", "created_at": "2024-01-15T10:30:00Z" }
]
}Delete a user. Requires authentication.
Behavior:
- Removes user from local database
- Deactivates user in Authentik (does not delete, for audit trail)
- Invalidates all active sessions for that user
Error cases:
- 401 Unauthorized - Not authenticated
- 403 Forbidden - Cannot delete yourself
- 404 Not Found - User doesn't exist
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := validateUsername(req.Username); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
existing, err := s.userStore.GetByUsername(req.Username)
if err != nil {
http.Error(w, "Failed to check existing user", http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, "Username already exists", http.StatusBadRequest)
return
}
_, err = s.authentikClient.CreateUser(req.Username, req.Password)
if err != nil {
http.Error(w, "Failed to create user in Authentik", http.StatusInternalServerError)
return
}
if err := s.userStore.Create(req.Username); err != nil {
http.Error(w, "Failed to create local user", http.StatusInternalServerError)
return
}
user, _ := s.userStore.GetByUsername(req.Username)
json.NewEncoder(w).Encode(user)
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.userStore.List()
if err != nil {
http.Error(w, "Failed to list users", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
userID, _ := strconv.Atoi(chi.URLParam(r, "id"))
currentUser := getUserFromContext(r.Context())
if currentUser.ID == userID {
http.Error(w, "Cannot delete yourself", http.StatusForbidden)
return
}
user, err := s.userStore.GetByID(userID)
if err != nil || user == nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err := s.authentikClient.DeactivateUser(user.Username); err != nil {
// Log but continue - local deletion is the priority
}
if err := s.userStore.Delete(userID); err != nil {
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
return
}
s.sessionStore.DeleteByUserID(userID)
w.WriteHeader(http.StatusNoContent)
}r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", s.handleListUsers)
r.Post("/", s.handleCreateUser)
r.Delete("/{id}", s.handleDeleteUser)
})
})Unit Tests:
func TestCreateUser_DuplicateUsername(t *testing.T) {
server := setupTestServer(t)
adminSession := createAdminSession(t, server)
server.userStore.Create("existinguser")
body := `{"username": "existinguser", "password": "testpass123"}`
req := httptest.NewRequest("POST", "/api/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "session", Value: adminSession})
rec := httptest.NewRecorder()
server.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestDeleteUser_CannotDeleteSelf(t *testing.T) {
server := setupTestServer(t)
server.userStore.Create("admin")
admin, _ := server.userStore.GetByUsername("admin")
session, _ := server.sessionStore.Create(admin.ID, 24*time.Hour)
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/users/%d", admin.ID), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: session.ID})
rec := httptest.NewRecorder()
server.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
}Integration Tests:
func TestCreateUser_Success(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
server := setupIntegrationServer(t)
adminSession := createAdminSession(t, server)
body := `{"username": "newuser", "password": "testpass123"}`
req := httptest.NewRequest("POST", "/api/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "session", Value: adminSession})
rec := httptest.NewRecorder()
server.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var user User
json.Unmarshal(rec.Body.Bytes(), &user)
assert.Equal(t, "newuser", user.Username)
}Manual Verification:
- Navigate to Settings > Users
- Click "Add User", enter credentials
- User appears in list
- Log out, log in as new user
- Delete user (not yourself)
- Deleted user cannot log in
-
Role-Based Access Control: Currently all authenticated users can manage users. Should restrict to admins only. Requires adding
roleoris_admincolumn. -
Invite Flow: Current flow requires admin to set password. Better UX would be invite link where user sets their own password.
-
Password Policy Display: No visibility into Authentik's password requirements. Should fetch and display password policy in UI.
-
Transactional Consistency: Creating user in Authentik then locally isn't atomic. If local creation fails, orphan user exists in Authentik. Should implement compensation/rollback.
-
Audit Logging: No record of who created/deleted users. Add audit log table for compliance.
- Session cookies: HTTP-only, Secure (in production), SameSite=Lax
- CSRF protection: For state-changing requests
- Session invalidation: On logout, on password change
- Rate limiting: On login endpoints
For existing installations (users exist but no session):
- All requests will get 401 until user logs in
- Frontend detects 401 and shows login page
- Navigate to http://localhost:8080
- Setup wizard appears
- Create admin user with valid credentials
- Redirected to login page
- Login with created credentials
- Dashboard loads successfully
- Close browser, reopen
- Session remains valid (no re-login required)
- After 7 days (or configured expiry), session expires
- Navigate to Settings > Users
- Click "Add User"
- Enter new username and password
- User appears in list
- Log out, log in as new user
- As admin, delete a non-admin user
- User removed from list
- Deleted user cannot log in
- Cannot delete yourself
- Without session:
/api/appsreturns 401 - Without session:
/api/setup/statusreturns 200 (public) - With valid session:
/api/appsreturns 200
- Click logout
- Session invalidated
- Redirected to login page
- Back button doesn't restore session
// test_helpers.go
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("postgres", "postgres://test:test@localhost/bloud_test?sslmode=disable")
require.NoError(t, err)
_, err = db.Exec(schemaSQL)
require.NoError(t, err)
t.Cleanup(func() {
db.Exec("TRUNCATE users, sessions CASCADE")
db.Close()
})
return db
}
func setupTestServer(t *testing.T) *Server {
db := setupTestDB(t)
return NewServer(Config{
DB: db,
AuthentikClient: &MockAuthentikClient{},
})
}
func createAdminSession(t *testing.T, s *Server) string {
s.userStore.Create("admin")
user, _ := s.userStore.GetByUsername("admin")
session, _ := s.sessionStore.Create(user.ID, 24*time.Hour)
return session.ID
}
type MockAuthentikClient struct{}
func (m *MockAuthentikClient) CreateUser(username, password string) (int, error) {
return 1, nil
}
func (m *MockAuthentikClient) DeactivateUser(username string) error {
return nil
}
func (m *MockAuthentikClient) IsAvailable() bool {
return true
}- Do we need role-based access control? (admin vs regular user)
- Should API routes support both session and API token auth?
- Should we add email field to users table for notifications?
New files:
internal/api/auth.go- Auth handlers (login, callback, logout, me)internal/api/users.go- User management handlersinternal/auth/oidc.go- OIDC client wrapperinternal/store/sessions.go- Session storeweb/src/lib/stores/auth.ts- Auth stateweb/src/lib/components/LoginPage.svelte- Login UI
Modified files:
internal/db/schema.sql- Add sessions tableinternal/api/routes.go- Add auth routes, protect existing routesinternal/api/server.go- Initialize OIDC clientapps/authentik/configurator.go- Create Bloud OAuth appweb/src/routes/+layout.svelte- Add auth check