305 lines
6.8 KiB
Markdown
305 lines
6.8 KiB
Markdown
# Authentication Guide
|
|
|
|
The authentication system uses Stytch B2B for identity management with JWT verification, RBAC, and multi-tenant organization context.
|
|
|
|
## Architecture
|
|
|
|
**Provider**: Stytch B2B handles user authentication and sessions
|
|
**Middleware**: Verifies JWTs and resolves organization/account context
|
|
**RBAC**: Role-based access control with permissions
|
|
**Resolvers**: Bridge auth provider IDs to database IDs
|
|
|
|
## JWT Verification
|
|
|
|
The system uses a two-tier verification strategy:
|
|
|
|
**1. Fast Path** - Verify JWT locally using cached public keys
|
|
**2. API Fallback** - Call Stytch API if local verification fails
|
|
|
|
This approach balances security with performance.
|
|
|
|
### Configuration
|
|
|
|
```env
|
|
STYTCH_PROJECT_ID=project-test-xxx
|
|
STYTCH_SECRET=secret-test-xxx
|
|
STYTCH_ENV=test # or "live"
|
|
```
|
|
|
|
## Middleware
|
|
|
|
Three middleware functions protect routes:
|
|
|
|
### RequireAuth
|
|
|
|
Verifies JWT and extracts identity.
|
|
|
|
```go
|
|
router.Use(authMiddleware.RequireAuth())
|
|
```
|
|
|
|
**What it does:**
|
|
- Verifies JWT from `Authorization: Bearer {token}` header
|
|
- Extracts user identity (email, roles, permissions)
|
|
- Stores `auth.Identity` in request context
|
|
- Returns 401 if auth fails
|
|
|
|
### RequireOrganization
|
|
|
|
Resolves organization and account IDs from auth provider.
|
|
|
|
```go
|
|
router.Use(authMiddleware.RequireOrganization())
|
|
```
|
|
|
|
**What it does:**
|
|
- Gets organization ID from Stytch → resolves to database ID
|
|
- Gets user email → resolves to account ID
|
|
- Stores `auth.RequestContext` with IDs
|
|
- Returns 401 if resolution fails
|
|
|
|
**Note:** Always use after `RequireAuth()`.
|
|
|
|
### RequirePermission
|
|
|
|
Checks user has specific permission.
|
|
|
|
```go
|
|
router.POST("/resources",
|
|
auth.RequirePermissionFunc("resource", "create"),
|
|
handler.CreateResource)
|
|
```
|
|
|
|
**What it does:**
|
|
- Checks if user has permission (e.g., `"resource:create"`)
|
|
- Returns 403 if permission missing
|
|
|
|
**Note:** Use after `RequireOrganization()`.
|
|
|
|
## Using Context in Handlers
|
|
|
|
Access authentication info from request context:
|
|
|
|
```go
|
|
func (h *Handler) MyHandler(c *gin.Context) {
|
|
// Get full context
|
|
reqCtx := auth.GetRequestContext(c)
|
|
orgID := reqCtx.OrganizationID // int32
|
|
accountID := reqCtx.AccountID // int32
|
|
email := reqCtx.Identity.Email // string
|
|
|
|
// Or use convenience functions
|
|
orgID := auth.GetOrganizationID(c)
|
|
accountID := auth.GetAccountID(c)
|
|
}
|
|
```
|
|
|
|
## RBAC System
|
|
|
|
### Roles
|
|
|
|
Defined in `src/pkg/auth/roles.go`:
|
|
|
|
- `RoleAdmin` - Full system access
|
|
- `RoleManager` - Organization management
|
|
- `RoleMember` - Standard user access
|
|
|
|
### Permissions
|
|
|
|
Format: `"{resource}:{action}"`
|
|
|
|
**Common permissions:**
|
|
- `resource:view` - Read access
|
|
- `resource:create` - Create new items
|
|
- `resource:update` - Modify existing items
|
|
- `resource:delete` - Delete items
|
|
- `org:manage` - Organization administration
|
|
|
|
Defined in `src/pkg/auth/permissions.go`.
|
|
|
|
### Permission Checks
|
|
|
|
```go
|
|
// In middleware (route-level)
|
|
router.POST("/resources",
|
|
auth.RequirePermissionFunc("resource", "create"),
|
|
handler.CreateResource)
|
|
|
|
// In code (programmatic)
|
|
if !auth.HasPermission(identity, "resource:delete") {
|
|
return errors.New("permission denied")
|
|
}
|
|
```
|
|
|
|
## Resolver Pattern
|
|
|
|
Resolvers convert auth provider IDs to database IDs.
|
|
|
|
### Why Needed?
|
|
|
|
- Stytch uses string UUIDs for organizations
|
|
- Database uses int32 for primary keys
|
|
- Auth package can't depend on domain modules (circular dependency)
|
|
|
|
### How It Works
|
|
|
|
**1. Auth package defines interfaces:**
|
|
|
|
```go
|
|
type OrganizationResolver interface {
|
|
ResolveByProviderID(ctx context.Context, providerID string) (int32, error)
|
|
}
|
|
```
|
|
|
|
**2. Domain modules implement via adapters:**
|
|
|
|
```go
|
|
type orgResolverAdapter struct {
|
|
repo domain.OrganizationRepository
|
|
}
|
|
|
|
func (a *orgResolverAdapter) ResolveByProviderID(ctx context.Context, id string) (int32, error) {
|
|
org, err := a.repo.GetByStytchID(ctx, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return org.ID, nil
|
|
}
|
|
```
|
|
|
|
**3. Wired in initialization:**
|
|
|
|
Resolvers registered in `src/main/cmd/init_mods.go` after organization module loads.
|
|
|
|
## Route Protection Patterns
|
|
|
|
### Public Route (No Auth)
|
|
|
|
```go
|
|
router.GET("/health", handler.Health)
|
|
```
|
|
|
|
### Authenticated Route
|
|
|
|
```go
|
|
apiGroup := router.Group("/api")
|
|
apiGroup.Use(authMiddleware.RequireAuth())
|
|
apiGroup.Use(authMiddleware.RequireOrganization())
|
|
{
|
|
apiGroup.GET("/profile", handler.GetProfile)
|
|
}
|
|
```
|
|
|
|
### Permission-Protected Route
|
|
|
|
```go
|
|
apiGroup.POST("/resources",
|
|
auth.RequirePermissionFunc("resource", "create"),
|
|
handler.CreateResource)
|
|
|
|
apiGroup.DELETE("/resources/:id",
|
|
auth.RequirePermissionFunc("resource", "delete"),
|
|
handler.DeleteResource)
|
|
```
|
|
|
|
### Role-Protected Route
|
|
|
|
```go
|
|
adminGroup := router.Group("/admin")
|
|
adminGroup.Use(authMiddleware.RequireRole(auth.RoleAdmin))
|
|
{
|
|
adminGroup.GET("/users", handler.ListUsers)
|
|
}
|
|
```
|
|
|
|
## Adding New Permissions
|
|
|
|
**1. Define permission constant** in `src/pkg/auth/permissions.go`:
|
|
|
|
```go
|
|
const PermResourceView = Permission("resource:view")
|
|
const PermResourceCreate = Permission("resource:create")
|
|
```
|
|
|
|
**2. Assign to roles** in `src/pkg/auth/rbac.go`:
|
|
|
|
```go
|
|
{
|
|
RoleMember: {
|
|
PermResourceView,
|
|
// ... other permissions
|
|
},
|
|
RoleManager: {
|
|
PermResourceView,
|
|
PermResourceCreate,
|
|
// ... other permissions
|
|
},
|
|
}
|
|
```
|
|
|
|
**3. Protect routes**:
|
|
|
|
```go
|
|
router.POST("/resources",
|
|
auth.RequirePermissionFunc("resource", "create"),
|
|
handler.CreateResource)
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Check Organization Ownership
|
|
|
|
```go
|
|
func (h *Handler) GetResource(c *gin.Context) {
|
|
orgID := auth.GetOrganizationID(c)
|
|
resourceID := parseID(c.Param("id"))
|
|
|
|
resource, err := h.service.GetResource(c.Request.Context(), resourceID)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to get resource"})
|
|
return
|
|
}
|
|
|
|
// Verify resource belongs to user's organization
|
|
if resource.OrganizationID != orgID {
|
|
c.JSON(403, gin.H{"error": "access denied"})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, resource)
|
|
}
|
|
```
|
|
|
|
### Optional Authentication
|
|
|
|
```go
|
|
func (h *Handler) PublicResource(c *gin.Context) {
|
|
// Try to get org ID (may be 0 if not authenticated)
|
|
orgID := auth.GetOrganizationID(c)
|
|
|
|
if orgID != 0 {
|
|
// User is authenticated, show personalized data
|
|
} else {
|
|
// User is not authenticated, show public data
|
|
}
|
|
}
|
|
```
|
|
|
|
## File Locations
|
|
|
|
| Component | Path |
|
|
|-----------|------|
|
|
| Auth provider interface | `src/pkg/auth/auth.go` |
|
|
| Middleware | `src/pkg/auth/middleware.go` |
|
|
| Context helpers | `src/pkg/auth/context.go` |
|
|
| RBAC definitions | `src/pkg/auth/rbac.go` |
|
|
| Roles | `src/pkg/auth/roles.go` |
|
|
| Permissions | `src/pkg/auth/permissions.go` |
|
|
| Resolvers | `src/pkg/auth/resolvers.go` |
|
|
| Stytch adapter | `src/pkg/auth/adapters/stytch/` |
|
|
|
|
## Next Steps
|
|
|
|
- **Database operations**: See [Database Guide](./database.md)
|
|
- **Building APIs**: See [API Development Guide](./api-development.md)
|
|
- **Stytch documentation**: https://stytch.com/docs/b2b
|