Skip to content

Authentication & Authorization

This document explains how authentication and authorization work in the Rego backend.

Overview

The Rego API uses JWT (JSON Web Token) based authentication powered by the fastapi-users library. Users can authenticate using either Bearer tokens in headers or HTTP-only cookies, providing flexibility for different client types.

Authentication Methods

1. Bearer Token Authentication

The primary authentication method uses JWT tokens in the Authorization header:

GET /api/boards HTTP/1.1
Host: localhost:8000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Pros: - Standard REST API convention - Works with any HTTP client - Easy to implement in mobile apps

Cons: - Requires client-side token storage - Vulnerable to XSS if stored in localStorage

Alternatively, the JWT can be sent as an HTTP-only cookie:

GET /api/boards HTTP/1.1
Host: localhost:8000
Cookie: rego_auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Pros: - More secure (HTTP-only, can't be accessed by JavaScript) - Automatic inclusion in requests by browser - Better protection against XSS attacks

Cons: - Requires CSRF protection - Only works in browser environments

User Model

The User model extends fastapi-users' base user table:

Location: rego/core/models.py

class User(SQLAlchemyBaseUserTable[UUID], BaseTable):
    id: Mapped[UUID]
    email: Mapped[str]                    # Validated email
    username: Mapped[str]                 # Unique username
    hashed_password: Mapped[str]          # Argon2 hash

    firstname: Mapped[str | None]
    lastname: Mapped[str | None]

    # fastapi-users fields
    is_active: Mapped[bool]               # Can user log in?
    is_verified: Mapped[bool]             # Email verified?
    is_superuser: Mapped[bool]            # Admin privileges?

    # Relationships
    boards: Mapped[list[Board]]           # Boards user is member of
    assigned_cards: Mapped[list[Card]]    # Cards assigned to user

Password Security

  • Passwords are hashed using Argon2, the winner of the Password Hashing Competition
  • Plain passwords are never stored or logged
  • Password complexity requirements can be enforced at registration
  • Hashing is CPU-intensive by design to prevent brute-force attacks

Authentication Endpoints

All auth endpoints are prefixed with /auth.

Register

Create a new user account:

POST /auth/register
Content-Type: application/json

{
  "email": "user@example.com",
  "username": "johndoe",
  "password": "SecurePassword123!",
  "firstname": "John",
  "lastname": "Doe"
}

Response (201 Created):

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "user@example.com",
  "username": "johndoe",
  "firstname": "John",
  "lastname": "Doe",
  "is_active": true,
  "is_verified": false,
  "is_superuser": false
}

Validation: - Email must be valid format and unique - Username must be unique - Password must meet complexity requirements (if configured)

Login

Authenticate and receive a JWT token:

POST /auth/login
Content-Type: application/x-www-form-urlencoded

username=user@example.com&password=SecurePassword123!

Response (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}

Cookie variant: If using cookie auth, the token is set as an HTTP-only cookie in the response:

Set-Cookie: rego_auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; 
            Path=/; 
            HttpOnly; 
            SameSite=Lax; 
            Max-Age=3600

Logout

Invalidate the current session:

POST /auth/logout
Authorization: Bearer <token>

Response (200 OK):

{
  "detail": "Successfully logged out"
}

For cookie-based auth, this clears the cookie.

Get Current User

Retrieve the authenticated user's profile:

GET /auth/me
Authorization: Bearer <token>

Response (200 OK):

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "user@example.com",
  "username": "johndoe",
  "firstname": "John",
  "lastname": "Doe",
  "is_active": true,
  "is_verified": false,
  "is_superuser": false
}

Update Profile

Modify the current user's profile:

PATCH /auth/me
Authorization: Bearer <token>
Content-Type: application/json

{
  "firstname": "Jonathan",
  "lastname": "Doe"
}

Response (200 OK):

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "user@example.com",
  "username": "johndoe",
  "firstname": "Jonathan",
  "lastname": "Doe",
  "is_active": true,
  "is_verified": false,
  "is_superuser": false
}

Change Password

Update the user's password:

POST /auth/change-password
Authorization: Bearer <token>
Content-Type: application/json

{
  "current_password": "OldPassword123!",
  "new_password": "NewSecurePassword456!"
}

Response (200 OK):

{
  "detail": "Password changed successfully"
}

JWT Token Structure

The JWT contains the following claims:

{
  "sub": "123e4567-e89b-12d3-a456-426614174000",  // User ID
  "aud": ["fastapi-users:auth"],                   // Audience
  "exp": 1735689600,                               // Expiration timestamp
  "iat": 1735686000                                // Issued at timestamp
}

Token Configuration

Location: rego/core/settings/jwt.py

class JWTSettings(BaseSettings):
    secret: str = "CHANGE_ME"           # Signing key (MUST change in production)
    algorithm: str = "HS256"            # Signing algorithm
    lifetime_seconds: int = 3600        # Token valid for 1 hour
    cookie_name: str = "rego_auth"      # Cookie name for cookie-based auth

Security Requirements:

In production: - Secret must be at least 32 characters - Secret must be different from default "CHANGE_ME" - Use a cryptographically random string - Never commit secrets to version control

Authorization System

Authorization in Rego is based on two levels: system-level and board-level permissions.

System-Level Permissions

Controlled by the is_superuser flag on the User model:

current_superuser = fastapi_users.current_user(active=True, superuser=True)

Superusers can: - Access debug endpoints (if enabled) - View system-wide statistics - Perform administrative tasks

Board-Level Permissions

Each user's relationship to a board is defined by their role in the BoardMember table:

class BoardMember(BaseLinkTable):
    board_id: Mapped[UUID]
    user_id: Mapped[UUID]
    role: Mapped[MemberRole]  # owner or member

Member Roles

class MemberRole(str, Enum):
    owner = "owner"      # Full control over board
    member = "member"    # Can view and edit, but limited admin

Owner permissions: - Update board details (title, description) - Delete the board - Add/remove members - Create/modify automation rules - All member permissions

Member permissions: - View board and all its contents - Create/update/delete columns - Create/update/delete cards - Create/update/delete labels - Assign themselves and other members to cards - Create/update/delete checklists and items

Access Control Dependencies

The access domain provides reusable dependencies for enforcing board-level permissions.

Location: rego/domains/access/dependencies.py

Require Board Access

Ensures the authenticated user is a member of the board with an optional minimum role:

@router.delete("/boards/{board_id}")
async def delete_board(
    board_id: UUID,
    user: User = Depends(require_board_access(MemberRole.owner)),
):
    # Only board owners can reach this code
    ...

Usage patterns:

# Require any membership (default)
Depends(require_board_access())

# Require owner role
Depends(require_board_access(MemberRole.owner))

# Require member role (explicitly)
Depends(require_board_access(MemberRole.member))

# Custom board resolver (for nested resources)
Depends(require_board_access(
    MemberRole.member,
    get_board=get_board_from_card
))

Error responses:

  • 403 Forbidden: User is not a board member
  • 403 Forbidden: User has insufficient role (e.g., member trying to delete board)

Require Board Members

Validates that a list of users are all members of a board:

@router.post("/cards/{card_id}/assignees")
async def assign_users(
    card_id: UUID,
    user_ids: list[UUID],
    validated_users: list[User] = Depends(require_board_members()),
):
    # All users in user_ids are confirmed board members
    card.assignees.extend(validated_users)
    ...

Error responses:

  • 404 Not Found: One or more users don't exist
  • 403 Forbidden: One or more users are not board members

Require User Is Board Member

Validates that a specific user (from path parameter) is a board member:

@router.put("/cards/{card_id}/assignees/{user_id}")
async def assign_user(
    card_id: UUID,
    validated_user: User = Depends(require_user_is_board_member()),
):
    # The user from user_id is confirmed to be a board member
    card.assignees.append(validated_user)
    ...

Protecting Endpoints

Using Dependencies

The most common pattern is to use FastAPI's dependency injection:

from rego.domains.auth.dependencies import current_user
from rego.domains.access.dependencies import require_board_access

@router.post("/boards/{board_id}/columns")
async def create_column(
    board_id: UUID,
    payload: ColumnCreate,
    user: User = Depends(current_user),              # Must be authenticated
    board: Board = Depends(require_board_access()),  # Must be board member
):
    # Both checks passed, proceed with logic
    ...

Path-Level Dependencies

For multiple endpoints with the same requirements:

router = APIRouter(
    dependencies=[Depends(current_user)]  # All routes require authentication
)

@router.get("/boards")
async def list_boards():
    # Already authenticated
    ...

@router.get("/boards/{board_id}")
async def get_board():
    # Already authenticated
    ...

Operation-Level Dependencies

For specific routes only:

@router.delete(
    "/boards/{board_id}",
    dependencies=[Depends(require_board_access(MemberRole.owner))]
)
async def delete_board(board_id: UUID):
    # Only owners can delete
    ...

Security Best Practices

Password Storage

  • Never log or display passwords
  • Use Argon2 for hashing (built-in to fastapi-users)
  • Enforce minimum password complexity
  • Consider password breach checking (haveibeenpwned API)

Token Management

  • Keep token lifetime short (default: 1 hour)
  • Implement refresh tokens for long-lived sessions
  • Invalidate tokens on password change
  • Use HTTPS in production to prevent token interception

When using cookie-based auth:

# Production settings
cookie_secure = True          # HTTPS only
cookie_samesite = "lax"       # CSRF protection
cookie_httponly = True        # No JavaScript access
cookie_max_age = 3600         # 1 hour expiration

Environment-Specific Security

The application enforces security requirements based on environment:

# Development
JWT_SECRET = "CHANGE_ME"              # Allowed
CORS_ALLOWED_ORIGINS = "*"            # Allowed

# Production
JWT_SECRET = "CHANGE_ME"              # ERROR: Must be changed
JWT_SECRET = "short"                  # ERROR: Must be 32+ chars
CORS_ALLOWED_ORIGINS = "*"            # ERROR: Must specify exact origins

Common Authentication Flows

Web Application Flow

  1. User visits login page
  2. Client POSTs credentials to /auth/login
  3. Server returns JWT in HTTP-only cookie
  4. All subsequent requests include cookie automatically
  5. Server validates cookie on each request
  6. On logout, server clears cookie

Mobile/SPA Flow

  1. User enters credentials in app
  2. App POSTs to /auth/login
  3. Server returns JWT in response body
  4. App stores token in secure storage
  5. App includes Authorization: Bearer <token> header on each request
  6. Server validates token on each request
  7. On logout, app deletes stored token

WebSocket Authentication

WebSockets can't send custom headers, so token is passed as query parameter:

const token = localStorage.getItem('auth_token');
const ws = new WebSocket(`ws://localhost:8000/ws/boards/${boardId}?token=${token}`);

The server validates the token before accepting the connection:

@router.websocket("/boards/{board_id}")
async def board_websocket(websocket: WebSocket, board_id: UUID):
    token = websocket.query_params.get("token")
    user = await validate_token(token)

    if not user:
        await websocket.close(code=1008)  # Policy violation
        return

    # Check board membership
    is_member = await check_board_membership(user.id, board_id)
    if not is_member:
        await websocket.close(code=1008)
        return

    await manager.connect(board_id, websocket)
    ...

Testing Authentication

Getting a Test Token

In development, you can quickly get a token for testing:

  1. Register a user:

    curl -X POST http://localhost:8000/auth/register \
      -H "Content-Type: application/json" \
      -d '{
        "email": "test@example.com",
        "username": "testuser",
        "password": "testpass123"
      }'
    

  2. Login to get token:

    curl -X POST http://localhost:8000/auth/login \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "username=test@example.com&password=testpass123"
    

  3. Use token in requests:

    curl http://localhost:8000/boards \
      -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    

Testing Protected Endpoints

Without authentication:

curl http://localhost:8000/boards
# Response: 401 Unauthorized

With authentication:

curl http://localhost:8000/boards \
  -H "Authorization: Bearer <token>"
# Response: 200 OK with board list

With insufficient permissions:

curl -X DELETE http://localhost:8000/boards/{board_id} \
  -H "Authorization: Bearer <member_token>"
# Response: 403 Forbidden (only owners can delete)

Troubleshooting

"Could not validate credentials"

  • Token is malformed or corrupted
  • Token has expired (check exp claim)
  • Token was signed with different secret
  • User account was deactivated

"User must be a board member"

  • User is authenticated but not a member of the requested board
  • Board was deleted
  • User was removed from board

"Insufficient permissions"

  • User is a board member but lacks required role
  • Trying to perform owner-only action as member
  • Check role in BoardMember table

Token not found

  • Missing Authorization header
  • Header not formatted as "Bearer "
  • Cookie not being sent (check cookie settings)
  • CORS blocking credentials (need credentials: 'include')