Skip to content

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:

  1. Register via REST API (POST /api/v1/auth/register)
  2. Login via REST API (POST /api/v1/auth/login)
  3. Receive JWT access token + refresh token
  4. 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:

Username: admin
Password: admin123
Organization: default

⚠️ 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:

{
  "sub": "user-id",
  "token_id": "refresh-token-uuid",
  "exp": 1700504900,
  "iat": 1699899100
}

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:

  1. Client sends refresh token to /api/v1/auth/refresh
  2. Server validates refresh token
  3. Server marks old refresh token as used (one-time use)
  4. Server generates new access + refresh tokens
  5. Server stores new refresh token in database
  6. 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:

{
  "name": "admin",
  "permissions": ["file:*", "org:*", "user:*", "role:*", "group:*"]
}

Member Role:

{
  "name": "member",
  "permissions": ["file:read", "file:write", "org:read"]
}

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:

  1. Middleware/Interceptor: First line of defense
  2. Service Layer: Business logic checks
  3. 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:

query := `
    SELECT * FROM files
    WHERE id = ?
`
// Missing organization_id filter!

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