# Table of Contents

1. [Overview](#overview)
2. [Architecture](#architecture)
3. [JWT Claims Mapping](#jwt-claims-mapping)
4. [Keycloak Provider](#keycloak-provider)
5. [Auth Middleware](#auth-middleware)
6. [Auth Service](#auth-service)
7. [Role-Based Authorization](#role-based-authorization)
8. [User Auto-Creation](#user-auto-creation)

---

## Overview

Backend acts as **OAuth2 Resource Server** - validates Bearer tokens from UI/browser without needing to be an OIDC client. Token validation is done locally using JWKS fetched from Keycloak.

---

## Architecture

```
┌─────────┐     Bearer Token      ┌──────────────┐     JWKS      ┌────────────┐
│   UI    │ ───────────────────► │   Backend    │ ◄─────────── │  Keycloak  │
│ Browser │                      │ Resource Srv │   (cached)   │   JWKS     │
└─────────┘                      └──────────────┘              └────────────┘
                                       │
                                       ▼
                                ┌──────────────┐
                                │  PostgreSQL  │
                                │  (users)     │
                                └──────────────┘
```

**Key Design:**
- No client secret needed (Resource Server pattern)
- Local JWT validation via JWKS (no Keycloak call per request)
- User auto-created/updated on each authentication
- Roles from `resource_access.{client_id}.roles`

---

## JWT Claims Mapping

Based on your Keycloak setup:

| JWT Claim | Field | Description |
|-----------|-------|-------------|
| `nip` | `NIP` | Primary key (e.g., "p021050") |
| `name` | `Name` | Full name |
| `email` | `Email` | Email address |
| `resource_access.exodus.roles` | `Roles` | Authorization roles |

**Example JWT Payload:**

```json
{
  "sub": "4a098117-e595-411b-b8fc-4328fc14b0ba",
  "nip": "p021050",
  "name": "DR. CITRA ANGGREINI SEMBIRING",
  "email": "ctasbr@gmail.com",
  "resource_access": {
    "exodus": {
      "roles": ["exodus.user", "exodus.business-support"]
    },
    "exodus-admin": {
      "roles": ["team-trade.admin"]
    }
  }
}
```

---

## Keycloak Provider

Uses `keyfunc/v3` for JWKS fetching and JWT validation.

```go
package keycloak

import (
    "context"
    "fmt"
    "time"

    "github.com/MicahParks/keyfunc/v3"
    "github.com/golang-jwt/jwt/v5"
)

type Provider struct {
    jwks     *keyfunc.JWKS
    clientID string
}

type TokenClaims struct {
    jwt.RegisteredClaims

    NIP   string `json:"nip"`
    Name  string `json:"name"`
    Email string `json:"email"`

    ResourceAccess map[string]struct {
        Roles []string `json:"roles"`
    } `json:"resource_access"`
}

func NewProvider(ctx context.Context, jwksURL, clientID string) (*Provider, error) {
    jwks, err := keyfunc.Get(jwksURL, keyfunc.Options{
        RefreshInterval: time.Hour,
        RefreshRateLimit: time.Minute * 5,
    })
    if err != nil {
        return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
    }

    return &Provider{
        jwks:     jwks,
        clientID: clientID,
    }, nil
}

func (p *Provider) VerifyToken(tokenString string) (*TokenClaims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, p.jwks.Keyfunc)
    if err != nil {
        return nil, fmt.Errorf("invalid token: %w", err)
    }

    if !token.Valid {
        return nil, fmt.Errorf("token is not valid")
    }

    claims, ok := token.Claims.(*TokenClaims)
    if !ok {
        return nil, fmt.Errorf("failed to extract claims")
    }

    return claims, nil
}

func (p *Provider) ExtractRoles(claims *TokenClaims) []string {
    if client, ok := claims.ResourceAccess[p.clientID]; ok {
        return client.Roles
    }
    return nil
}
```

---

## Auth Middleware

```go
package middlewares

import (
    "net/http"
    "strings"

    "myapp/internal/core/domain"
    "myapp/internal/core/port/inbound"

    "github.com/gin-gonic/gin"
)

const UserContextKey = "authenticated_user"

func AuthMiddleware(authSvc inbound.AuthService) gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized,
                gin.H{"error": "missing authorization header"})
            return
        }

        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
            c.AbortWithStatusJSON(http.StatusUnauthorized,
                gin.H{"error": "invalid authorization format"})
            return
        }

        user, err := authSvc.Authenticate(c.Request.Context(), parts[1])
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized,
                gin.H{"error": err.Error()})
            return
        }

        c.Set(UserContextKey, user)
        c.Next()
    }
}

func RequireRole(authSvc inbound.AuthService, roles ...string) gin.HandlerFunc {
    return func(c *gin.Context) {
        user := GetUserFromContext(c)
        if user == nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized,
                gin.H{"error": "unauthenticated"})
            return
        }

        for _, role := range roles {
            if authSvc.HasRole(user, role) {
                c.Next()
                return
            }
        }

        c.AbortWithStatusJSON(http.StatusForbidden,
            gin.H{"error": "insufficient permissions"})
    }
}

func GetUserFromContext(c *gin.Context) *domain.User {
    if user, exists := c.Get(UserContextKey); exists {
        if u, ok := user.(*domain.User); ok {
            return u
        }
    }
    return nil
}
```

---

## Auth Service

```go
package usecase

import (
    "context"
    "time"

    "myapp/internal/core/domain"
    "myapp/internal/core/port/inbound"
    "myapp/internal/core/port/outbound"

    "github.com/pkg/errors"
)

type authService struct {
    keycloakProvider *keycloak.Provider
    userRepo        outbound.UserRepository
}

func NewAuthService(keycloakProvider *keycloak.Provider, userRepo outbound.UserRepository) inbound.AuthService {
    return &authService{
        keycloakProvider: keycloakProvider,
        userRepo:        userRepo,
    }
}

func (s *authService) Authenticate(ctx context.Context, token string) (*domain.User, error) {
    claims, err := s.keycloakProvider.VerifyToken(token)
    if err != nil {
        return nil, errors.Wrap(err, "token validation failed")
    }

    if claims.NIP == "" {
        return nil, errors.New("nip claim is required")
    }

    user := &domain.User{
        NIP:   claims.NIP,
        Name:  claims.Name,
        Email: claims.Email,
        Roles: s.keycloakProvider.ExtractRoles(claims),
    }

    if err := s.syncUser(ctx, user); err != nil {
        return nil, errors.Wrap(err, "failed to sync user")
    }

    existing, err := s.userRepo.GetByNIP(ctx, user.NIP)
    if err != nil {
        return nil, errors.Wrap(err, "failed to get user")
    }

    if existing == nil {
        return nil, errors.New("user not found")
    }

    if !existing.IsActive {
        return nil, errors.New("user is deactivated")
    }

    user.IsActive = existing.IsActive
    user.LastLoginAt = existing.LastLoginAt
    user.CreatedAt = existing.CreatedAt
    user.UpdatedAt = existing.UpdatedAt

    return user, nil
}

func (s *authService) syncUser(ctx context.Context, user *domain.User) error {
    existing, err := s.userRepo.GetByNIP(ctx, user.NIP)
    if err != nil {
        return err
    }

    now := time.Now()

    if existing == nil {
        user.IsActive = true
        user.LastLoginAt = now
        user.CreatedAt = now
        user.UpdatedAt = now
        return s.userRepo.Create(ctx, user)
    }

    existing.Name = user.Name
    existing.Email = user.Email
    existing.LastLoginAt = now
    existing.UpdatedAt = now

    return s.userRepo.Update(ctx, existing)
}

func (s *authService) HasRole(user *domain.User, role string) bool {
    for _, r := range user.Roles {
        if r == role {
            return true
        }
    }
    return false
}
```

---

## Role-Based Authorization

**Available roles** from `resource_access.{client_id}.roles`:

| Client ID | Roles |
|-----------|-------|
| `exodus` | `exodus.user`, `exodus.business-support`, `exodus.default` |
| `exodus-admin` | `team-trade.admin` |
| `portal` | `portal.user` |

**Usage:**

```go
// Require specific role
admin := v1.Group("/admin")
admin.Use(middlewares.RequireRole(authSvc, "team-trade.admin"))
{
    admin.GET("/dashboard", adminHandler.GetDashboard)
}

// Require any of multiple roles
manager := v1.Group("/manager")
manager.Use(middlewares.RequireRole(authSvc, "project-manager", "owner"))
{
    manager.POST("/projects", projectHandler.Create)
}
```

---

## User Auto-Creation

Users are automatically created/updated on successful authentication:

1. JWT verified via Keycloak JWKS
2. Claims extracted (nip, name, email, roles)
3. User synced to local PostgreSQL:
   - **New user:** Created with `is_active=true`
   - **Existing user:** Name/email updated, `last_login_at` refreshed
4. User record checked for `is_active` status
5. If `is_active=false`, authentication fails (401)

**Domain Model:**

```go
type User struct {
    NIP         string    `db:"nip" json:"nip"`
    Name        string    `db:"name" json:"name"`
    Email       string    `db:"email" json:"email"`
    IsActive    bool      `db:"is_active" json:"is_active"`
    LastLoginAt time.Time `db:"last_login_at" json:"last_login_at"`
    CreatedAt   time.Time `db:"created_at" json:"created_at"`
    UpdatedAt   time.Time `db:"updated_at" json:"updated_at"`
    Roles       []string  `db:"-" json:"roles,omitempty"`
}
```

**Note:** `Roles` is transient - not persisted, only from JWT.
