Authentication & Authorization Guide¶
Last Updated: 2025-11-23
This guide explains how authentication and authorization work in ocmonica, including JWT tokens, RBAC, multi-tenancy, and security best practices.
Table of Contents¶
Overview¶
Ocmonica implements a comprehensive security model with:
- JWT Authentication: RS256 algorithm with 4096-bit RSA keys
- RBAC Authorization: Role-based access control with organization-scoped permissions
- Multi-Tenancy: Complete data isolation between organizations
- API Keys: Long-lived tokens for service-to-service authentication
- Security Headers: OWASP-compliant headers for web security
- Rate Limiting: Protection against brute-force attacks
Authentication Flow¶
User Registration & Login¶
Current State: Authentication is REST-only. Clients must:
- Register via REST API (
POST /api/v1/auth/register) - Login via REST API (
POST /api/v1/auth/login) - Receive JWT access token + refresh token
- Use access token for both REST and gRPC requests
Complete Authentication Flow¶
The following diagram shows the complete authentication lifecycle from registration to logout:
sequenceDiagram
participant User
participant Client
participant REST as REST API
participant AuthSvc as Auth Service
participant TokenSvc as Token Service
participant UserRepo as User Repository
participant DB as SQLite
Note over User,DB: Registration Flow
User->>Client: Enter username, email, password
Client->>REST: POST /api/v1/auth/register
REST->>AuthSvc: Register(username, email, password)
AuthSvc->>AuthSvc: Hash password (bcrypt)
AuthSvc->>AuthSvc: Create default organization
AuthSvc->>UserRepo: Create user
UserRepo->>DB: INSERT INTO users
DB-->>UserRepo: User created
AuthSvc->>TokenSvc: Generate tokens
TokenSvc->>DB: Store refresh token
TokenSvc-->>AuthSvc: Access + Refresh tokens
AuthSvc-->>REST: User + Tokens
REST-->>Client: 200 OK (tokens)
Client->>Client: Store tokens
Note over User,DB: Login Flow
User->>Client: Enter username, password
Client->>REST: POST /api/v1/auth/login
REST->>AuthSvc: Login(username, password)
AuthSvc->>UserRepo: GetByUsername(username)
UserRepo->>DB: SELECT * FROM users
DB-->>UserRepo: User record
UserRepo-->>AuthSvc: User
AuthSvc->>AuthSvc: Verify password (bcrypt)
AuthSvc->>TokenSvc: Generate tokens
TokenSvc->>DB: Store refresh token
TokenSvc-->>AuthSvc: Access + Refresh tokens
AuthSvc-->>REST: Tokens
REST-->>Client: 200 OK (tokens)
Note over User,DB: API Request Flow
User->>Client: Perform action
Client->>REST: GET /api/v1/files<br/>(Authorization: Bearer token)
REST->>REST: Validate JWT
REST->>REST: Extract user claims
REST->>REST: Check permissions
REST-->>Client: 200 OK (data)
Note over User,DB: Token Refresh Flow
Client->>Client: Access token expired
Client->>REST: POST /api/v1/auth/refresh<br/>(refresh token)
REST->>TokenSvc: RefreshToken(token)
TokenSvc->>DB: Verify token exists & not revoked
DB-->>TokenSvc: Token valid
TokenSvc->>TokenSvc: Mark old token as used
TokenSvc->>TokenSvc: Generate new tokens
TokenSvc->>DB: Store new refresh token
TokenSvc-->>REST: New Access + Refresh tokens
REST-->>Client: 200 OK (new tokens)
Note over User,DB: Logout Flow
User->>Client: Logout
Client->>REST: POST /api/v1/auth/logout<br/>(refresh token)
REST->>TokenSvc: RevokeToken(token)
TokenSvc->>DB: UPDATE refresh_tokens<br/>SET revoked_at = NOW()
DB-->>TokenSvc: Token revoked
TokenSvc-->>REST: Success
REST-->>Client: 200 OK
Client->>Client: Clear tokens
Token Flow Diagram¶
Simplified token flow for common operations:
graph TB
Start([User Login])
Login[POST /api/v1/auth/login]
Tokens[Receive Access + Refresh Token]
StoreTokens[Store Tokens<br/>Access: Memory<br/>Refresh: Secure Storage]
UseAccess[API Request<br/>Authorization: Bearer access_token]
CheckExpiry{Access Token<br/>Expired?}
APISuccess[API Response<br/>200 OK]
RefreshAPI[POST /api/v1/auth/refresh<br/>Send refresh token]
NewTokens[Receive New Token Pair]
Logout[POST /api/v1/auth/logout<br/>Send refresh token]
ClearTokens[Clear All Tokens]
End([Session Ended])
Start --> Login
Login --> Tokens
Tokens --> StoreTokens
StoreTokens --> UseAccess
UseAccess --> CheckExpiry
CheckExpiry -->|No| APISuccess
CheckExpiry -->|Yes| RefreshAPI
RefreshAPI --> NewTokens
NewTokens --> StoreTokens
APISuccess --> UseAccess
UseAccess -.->|User Logout| Logout
Logout --> ClearTokens
ClearTokens --> End
classDef action fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef storage fill:#e1f5ff,stroke:#01579b,stroke-width:2px
classDef endpoint fill:#fce4ec,stroke:#880e4f,stroke-width:2px
class UseAccess,APISuccess action
class CheckExpiry decision
class StoreTokens,Tokens,NewTokens storage
class Login,RefreshAPI,Logout endpoint
Bootstrap Admin Account¶
On first run, ocmonica creates a default admin account:
⚠️ IMPORTANT: Change this password immediately in production!
Implementation: internal/service/bootstrap_service.go
JWT Tokens¶
Token Types¶
Access Token: - Purpose: Authenticate API requests - TTL: 15 minutes - Storage: Client memory (not localStorage for security) - Algorithm: RS256 (RSA-SHA256)
Refresh Token: - Purpose: Obtain new access tokens - TTL: 7 days - Storage: Secure HTTP-only cookie or client memory - Revocation: Stored in database for invalidation - Single-use: Automatic rotation on each refresh
Token Structure¶
Access Token Claims:
{
"sub": "user-id",
"username": "johndoe",
"email": "john@example.com",
"organization_id": "org-123",
"role": "admin",
"permissions": ["file:*", "org:*", "user:*"],
"exp": 1699900000,
"iat": 1699899100
}
Refresh Token Claims:
Key Generation¶
Development: Auto-generated in memory (insecure, for development only)
Production: Use external RSA keys
# Generate 4096-bit RSA keys
openssl genrsa -out keys/jwtRS256.key 4096
openssl rsa -in keys/jwtRS256.key -pubout -out keys/jwtRS256.key.pub
# Set permissions
chmod 600 keys/jwtRS256.key
chmod 644 keys/jwtRS256.key.pub
Configuration (via environment or YAML):
auth:
jwt:
private_key_path: /path/to/jwtRS256.key
public_key_path: /path/to/jwtRS256.key.pub
access_token_ttl: 15m
refresh_token_ttl: 168h # 7 days
Token Validation¶
REST Middleware (internal/api/rest/middleware/auth.go):
func JWTMiddleware(tokenService *service.TokenService) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
SigningMethod: "RS256",
SigningKey: tokenService.GetPublicKey(),
ContextKey: "user",
TokenLookup: "header:Authorization:Bearer ",
})
}
gRPC Interceptor (internal/api/grpc/interceptors/auth.go):
func NewAuthInterceptor(tokenService *service.TokenService) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
token := extractBearerToken(req.Header())
claims, err := tokenService.ValidateToken(token)
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, err)
}
// Add claims to context
ctx = context.WithValue(ctx, "user_id", claims.Subject)
ctx = context.WithValue(ctx, "organization_id", claims.OrganizationID)
// ...
return next(ctx, req)
}
}
}
Token Refresh Flow¶
Refresh Token Rotation:
- Client sends refresh token to
/api/v1/auth/refresh - Server validates refresh token
- Server marks old refresh token as used (one-time use)
- Server generates new access + refresh tokens
- Server stores new refresh token in database
- Client receives new token pair
Implementation: internal/service/token_service.go
Security Benefits: - Prevents replay attacks (old tokens invalid) - Limits exposure window (15min for access tokens) - Allows token revocation (refresh tokens in DB)
Authorization (RBAC)¶
Permission Model¶
Permission Format: resource:action
Examples:
- file:read - Read files
- file:write - Create/update files
- file:delete - Delete files
- file:* - All file permissions
- org:admin - Organization administration
- user:manage - User management
Wildcard Support:
- file:* - All actions on files
- org:* - All actions on organization
Built-in Roles¶
Admin Role:
Member Role:
Custom Roles¶
Administrators can create custom roles with any combination of permissions:
Create Custom Role (REST API):
curl -X POST http://localhost:8080/api/v1/roles \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "editor",
"description": "Can edit files but not delete",
"permissions": ["file:read", "file:write", "org:read"]
}'
Assign Role to User:
curl -X POST http://localhost:8080/api/v1/roles/<role_id>/assign \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user-123"
}'
Permission Enforcement¶
Ocmonica enforces permissions at multiple layers for defense in depth.
Multi-Level Enforcement Flow¶
graph TB
Request[API Request]
subgraph "Layer 1: Middleware/Interceptor"
ValidateJWT[Validate JWT Token]
ExtractClaims[Extract User Claims]
CheckBasicPerm[Check Required Permission]
RejectMW{Permission<br/>Granted?}
end
subgraph "Layer 2: Service Layer"
BusinessLogic[Business Logic]
CheckServicePerm[Check Detailed Permissions]
CheckOwnership[Check Resource Ownership]
RejectSvc{Permission<br/>Valid?}
end
subgraph "Layer 3: Repository Layer"
AddOrgFilter[Add org_id to WHERE clause]
ExecuteQuery[Execute SQL Query]
FilterResults[Filter by Organization]
end
subgraph "Database"
DB[(SQLite)]
end
Success[Return Data]
Reject403[403 Forbidden]
Reject404[404 Not Found]
Request --> ValidateJWT
ValidateJWT --> ExtractClaims
ExtractClaims --> CheckBasicPerm
CheckBasicPerm --> RejectMW
RejectMW -->|No| Reject403
RejectMW -->|Yes| BusinessLogic
BusinessLogic --> CheckServicePerm
CheckServicePerm --> CheckOwnership
CheckOwnership --> RejectSvc
RejectSvc -->|No| Reject403
RejectSvc -->|Yes| AddOrgFilter
AddOrgFilter --> ExecuteQuery
ExecuteQuery --> FilterResults
FilterResults --> DB
DB --> Success
DB -.->|No rows| Reject404
classDef layer1 fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
classDef layer2 fill:#fff9c4,stroke:#f57f17,stroke-width:2px
classDef layer3 fill:#fce4ec,stroke:#880e4f,stroke-width:2px
classDef database fill:#e0f2f1,stroke:#004d40,stroke-width:2px
classDef success fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
classDef error fill:#ffebee,stroke:#b71c1c,stroke-width:2px
class ValidateJWT,ExtractClaims,CheckBasicPerm,RejectMW layer1
class BusinessLogic,CheckServicePerm,CheckOwnership,RejectSvc layer2
class AddOrgFilter,ExecuteQuery,FilterResults layer3
class DB database
class Success success
class Reject403,Reject404 error
Enforcement Layers:
- Middleware/Interceptor: First line of defense
- Service Layer: Business logic checks
- Repository: Organization scoping (data isolation)
REST Middleware (internal/api/rest/middleware/permission.go):
func RequirePermission(permission string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
permissions := c.Get("permissions").([]string)
if !hasPermission(permissions, permission) {
return echo.ErrForbidden
}
return next(c)
}
}
}
gRPC Interceptor (internal/api/grpc/interceptors/permission.go):
func NewPermissionInterceptor(permissionService *service.PermissionService, requiredPermission string) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
if !permissionService.HasPermission(ctx, requiredPermission) {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("permission denied"))
}
return next(ctx, req)
}
}
}
Service Layer Checks:
func (s *FileService) DeleteFile(ctx context.Context, fileID string) error {
// Extract user context
userID := ctx.Value("user_id").(string)
orgID := ctx.Value("organization_id").(string)
// Check permission
if !s.permissionService.HasPermission(ctx, "file:delete") {
return ErrPermissionDenied
}
// Get file to verify ownership and organization
file, err := s.fileRepo.GetByID(ctx, fileID)
if err != nil {
return err
}
// Verify organization isolation
if file.OrganizationID != orgID {
return ErrPermissionDenied
}
// Perform deletion
return s.fileRepo.Delete(ctx, fileID)
}
Multi-Tenancy¶
Data Isolation¶
Every resource in ocmonica has an organization_id column:
CREATE TABLE files (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
owner_id TEXT NOT NULL,
-- ...
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
CREATE INDEX idx_files_organization_id ON files(organization_id);
Critical Security Rule: All queries MUST filter by organization_id
Correct:
query := `
SELECT * FROM files
WHERE organization_id = ? AND id = ?
`
db.QueryRow(query, orgID, fileID).Scan(...)
WRONG - Security Vulnerability:
Organization Context¶
User context includes current organization from JWT token:
// Context populated by auth middleware/interceptor
ctx = context.WithValue(ctx, "organization_id", claims.OrganizationID)
// Services extract organization ID
orgID, ok := ctx.Value("organization_id").(string)
if !ok {
return errors.New("no organization in context")
}
Cross-Organization Access¶
Cross-organization access is explicitly forbidden:
- Users can only access resources in their organization
- API calls automatically filtered by organization
- Attempting to access another org's resources returns 404 (not 403, to avoid information disclosure)
Implementation: internal/repository/sqlite/*.go - All repository methods
Security Best Practices¶
Password Security¶
Hashing: - Algorithm: bcrypt - Cost factor: 12 (4096 iterations) - Automatic salt generation
Never: - Log passwords - Store passwords in plaintext - Send passwords in URLs or query parameters - Return passwords in API responses
Implementation: internal/service/auth_service.go
Token Security¶
Storage: - ✅ Access tokens: Client memory (not localStorage) - ✅ Refresh tokens: Secure HTTP-only cookies or encrypted storage - ❌ Never store tokens in localStorage (XSS vulnerability)
Transmission:
- ✅ Always use HTTPS in production
- ✅ Send tokens in Authorization: Bearer <token> header
- ❌ Never send tokens in URL query parameters
Revocation: - All tokens revoked on password change - Refresh tokens revoked on logout - Database stores active refresh tokens for revocation
Rate Limiting¶
Configured per endpoint:
// Global: 100 req/min per IP
rateLimitMiddleware(100, time.Minute)
// Login: 5 req/min per IP (brute-force protection)
authGroup.POST("/login", authHandler.Login, rateLimitMiddleware(5, time.Minute))
// Other auth: 10 req/min per IP
authGroup.POST("/register", authHandler.Register, rateLimitMiddleware(10, time.Minute))
Rate Limit Headers:
- X-RateLimit-Limit: Maximum requests allowed
- X-RateLimit-Remaining: Remaining requests
- X-RateLimit-Reset: Time when limit resets (Unix timestamp)
Note: Current implementation is in-memory (per instance). For distributed deployments, use Redis.
Security Headers¶
Configured in middleware:
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
ContentSecurityPolicy: "default-src 'self'; frame-ancestors 'none'",
XFrameOptions: "DENY",
XContentTypeOptions: "nosniff",
HSTSMaxAge: 63072000,
HSTSPreloadEnabled: true,
}))
Headers Applied:
- Content-Security-Policy: default-src 'self'; frame-ancestors 'none'
- X-Frame-Options: DENY
- X-Content-Type-Options: nosniff
- Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
- Referrer-Policy: strict-origin-when-cross-origin
Input Validation¶
Always Validate:
- File names (no path traversal: ../, etc.)
- File sizes (enforce max upload size)
- User input fields (email format, username characters)
- MIME types (check against whitelist)
Example:
// Prevent path traversal
if strings.Contains(filename, "..") || strings.Contains(filename, "/") {
return ErrInvalidFilename
}
// Validate email format
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(email) {
return ErrInvalidEmail
}
API Examples¶
User Registration¶
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "john@example.com",
"password": "SecureP@ssw0rd123",
"organization_name": "ACME Corporation"
}'
Response:
{
"user": {
"id": "user-123",
"username": "johndoe",
"email": "john@example.com",
"organization_id": "org-456"
},
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900
}
User Login¶
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"password": "SecureP@ssw0rd123"
}'
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900
}
Refresh Access Token¶
curl -X POST http://localhost:8080/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "eyJhbGciOiJSUzI1NiIs..."
}'
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900
}
Change Password¶
curl -X PUT http://localhost:8080/api/v1/auth/password \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"old_password": "admin123",
"new_password": "NewSecureP@ssw0rd456"
}'
Note: All refresh tokens are revoked when password is changed.
See Also¶
- REST API Documentation
- gRPC API Documentation
- Architecture Overview
- See
SECURITY.mdin the project root for security documentation - See
DEVELOPMENT.mdin the project root for development guide