Skip to main content

Authentication & Authorization

Easy AppServer implements a comprehensive security model with dual authentication contexts (users and apps), certificate-based request signing, and fine-grained relationship-based authorization via OpenFGA.

Overview

The platform uses two distinct authentication contexts that work together to secure all operations:

  • UserAuthContext: For human users accessing the platform via browser/UI
  • AppAuthContext: For applications communicating with AppServer via gRPC

Both contexts integrate with OpenFGA for fine-grained authorization decisions based on relationship tuples.

Code Reference: pkg/v2/domain/auth/context.go:5

Authentication Contexts

AuthContext Interface

// AuthContext is the common interface for authentication contexts
type AuthContext interface {
// IsUser returns true if this is a user authentication context
IsUser() bool

// IsApp returns true if this is an app authentication context
IsApp() bool

// GetIdentifier returns a string identifier for logging/debugging
GetIdentifier() string
}

Both user and app contexts implement this interface, allowing middleware and services to handle both authentication types uniformly.

UserAuthContext

Represents an authenticated user session from Ory Kratos.

Code Reference: pkg/v2/domain/auth/context.go:28

type UserContext struct {
UserID uuid.UUID
SessionID string
Email string
Verified bool // Email verification status
}

Fields:

  • UserID: Unique identifier from Kratos identity
  • SessionID: Kratos session identifier for session tracking
  • Email: User's email address from Kratos identity
  • Verified: Email verification status (true if email confirmed)

AppAuthContext

Represents an authenticated application via signature verification.

Code Reference: pkg/v2/domain/auth/context.go:68

type AppContext struct {
AppID uuid.UUID
AppName string
Verified bool // Signature verification status
IsProvisional bool // True for bootstrap registration
}

Fields:

  • AppID: Application's unique identifier
  • AppName: Application name (e.g., "de.easy-m.todos")
  • Verified: True if signature verification succeeded
  • IsProvisional: True for bootstrap registration (app doesn't exist in DB yet)

User Authentication

User authentication is handled by Ory Kratos, an open-source identity and user management system.

Identity Management

Kratos provides comprehensive identity features:

  • User registration and login
  • Email/password authentication
  • Social login (OAuth2 providers: Google, GitHub, etc.)
  • Multi-factor authentication (MFA) via TOTP
  • Account recovery flows (password reset, account verification)
  • Profile management (update email, password)

Session Management

Kratos manages user sessions with secure, HTTP-only cookies:

  • Secure cookie-based sessions
  • Session validation on every request
  • Automatic session renewal before expiration
  • Logout and session revocation
  • Session lifetime configuration

Session Cookie:

  • Cookie name: APPSERVER_SESSION_COOKIE_NAME (default: ory_kratos_session)
  • Attributes: HTTP-only, Secure, SameSite=Lax
  • Validated on every HTTP request to platform APIs

User Authentication Flow

User accesses Shell:
├─ 1. Browser loads Shell application
├─ 2. Shell checks for Kratos session cookie
├─ 3. If not authenticated:
│ ├─ Redirect to Kratos login UI
│ ├─ User enters credentials
│ ├─ Kratos validates credentials
│ └─ Kratos creates session and sets cookie
├─ 4. Shell validates session with AppServer
├─ 5. AppServer extracts user ID from session
├─ 6. AppServer checks user permissions via OpenFGA
└─ 7. User granted access if authorized

Session Validation

The AppServer validates Kratos sessions using the Kratos admin API:

// Validate session cookie
session, err := kratosClient.ToSession(ctx, sessionToken)
if err != nil {
return nil, ErrInvalidSession
}

// Extract user context
userContext := NewUserContext(
session.Identity.ID,
session.ID,
session.Identity.Traits.Email,
session.Identity.VerifiableAddresses[0].Verified,
)

Sessions are checked on every request to ensure they haven't expired or been revoked.

Application Authentication

Applications authenticate to the AppServer using X.509 certificates and cryptographic request signatures.

Certificate-Based Authentication

Each application receives:

  1. X.509 certificate - Signed by AppServer CA, contains app name in CN
  2. Private key - For signing requests (kept secret by app)
  3. App ID - Embedded in certificate metadata

Certificates establish the app's identity and provide the public key for signature verification.

Request Signing Protocol

Every gRPC request from an application must be signed with its private key.

Code Reference: pkg/v2/infrastructure/auth/signature.go:25

Signature Generation (Client-Side)

// 1. Construct message
const method = "GRPC";
const grpcMethod = "/easy.v2.Marketplace/Subscribe";
const timestamp = new Date().toISOString(); // RFC3339
const appName = "de.easy-m.todos";

const message = `${method}\n${grpcMethod}\n${timestamp}\n${appName}`;

// 2. Sign message with private key (RSA-SHA256)
const signature = crypto.sign("RSA-SHA256", Buffer.from(message), privateKey);
const signatureBase64 = signature.toString('base64');

// 3. Add metadata headers
metadata.set("x-app-name", appName);
metadata.set("x-app-timestamp", timestamp);
metadata.set("x-app-signature", signatureBase64);

Code Reference: pkg/v2/infrastructure/auth/signature.go:25

// Message construction
func ConstructMessage(method, pathWithQuery, timestamp, appName string) string {
return fmt.Sprintf("%s\n%s\n%s\n%s", method, pathWithQuery, timestamp, appName)
}

// Signature creation
func SignMessage(message string, privateKey *rsa.PrivateKey) (string, error) {
hashed := sha256.Sum256([]byte(message))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(signature), nil
}

Signature Verification (Server-Side)

Code Reference: pkg/v2/presentation/grpc/grpcauth/authenticator.go:85

Signature Verification Flow:
├─ 1. Extract metadata headers (x-app-name, x-app-timestamp, x-app-signature)
├─ 2. Parse and validate timestamp
│ ├─ Check timestamp format (RFC3339)
│ ├─ Verify timestamp is not too old (< replay window)
│ └─ Verify timestamp is not too far in future (< clock skew tolerance)
├─ 3. Fetch app from database by name
├─ 4. Extract public key from app's certificate
├─ 5. Reconstruct message from request metadata
├─ 6. Verify signature matches message using public key
└─ 7. Create AppContext if verification succeeds
func (a *GRPCAuthenticator) AuthenticateApp(ctx context.Context, md metadata.MD, method string) (auth.AuthContext, error) {
// Extract credentials from metadata
appName, timestampStr, signature := extractor.ExtractAppCredentials()

// Validate timestamp
timestamp, err := time.Parse(time.RFC3339, timestampStr)
age := time.Now().Sub(timestamp)

if age > a.config.SignatureReplayWindow {
return nil, &auth.ErrExpiredSignature{Age: age.String()}
}

// Fetch app and extract public key
app, err := a.appRepo.FindByName(ctx, appName)
publicKey, err := x509.ParsePKIXPublicKey(app.Certificate.PublicKey)

// Construct message and verify signature
message := infraAuth.ConstructMessage("GRPC", method, timestampStr, appName)
err = infraAuth.VerifySignature(message, signature, publicKey)

if err != nil {
return nil, auth.ErrSignatureVerificationFailed
}

return auth.NewAppContext(app.ID, appName, true), nil
}

gRPC Metadata Headers

Applications must include these headers in gRPC metadata:

Required Headers:

  • x-app-name: Application name (e.g., "de.easy-m.todos")
  • x-app-timestamp: RFC3339 timestamp (e.g., "2025-01-24T10:00:00Z")
  • x-app-signature: Base64-encoded RSA-SHA256 signature

Example:

x-app-name: de.easy-m.billing
x-app-timestamp: 2025-01-24T15:30:00Z
x-app-signature: dGVzdHNpZ25hdHVyZWhlcmU=

Security Mechanisms

Replay Protection

Prevents replay attacks by checking request timestamps.

Configuration:

  • APPSERVER_SIGNATURE_REPLAY_WINDOW (default: 5m)

Validation:

age := time.Now().Sub(timestamp)
if age > a.config.SignatureReplayWindow {
return nil, &auth.ErrExpiredSignature{Age: age.String()}
}

Requests older than the replay window are rejected, preventing attackers from reusing captured requests.

Clock Skew Tolerance

Allows for time differences between client and server systems.

Configuration:

  • APPSERVER_CLOCK_SKEW_TOLERANCE (default: 30s)

Validation:

if timestamp.After(now.Add(a.config.ClockSkewTolerance)) {
return nil, &auth.ErrExpiredSignature{
Age: fmt.Sprintf("timestamp in future: %v", timestamp.Sub(now)),
}
}

Requests with timestamps too far in the future are rejected (with tolerance for clock drift).

Bootstrap Registration

For development environments, apps can register without pre-issued certificates.

Configuration:

  • APPSERVER_AUTH_ALLOW_BOOTSTRAP_REGISTRATION (default: true in dev, false in prod)

Flow:

if a.config.AllowBootstrapRegistration && method == "/easy.v2.Marketplace/Subscribe" {
// Return provisional context for new apps during registration
return auth.NewProvisionalAppContext(appName), nil
}

Provisional Context:

  • App generates self-signed certificate
  • AppServer issues CA-signed certificate during registration
  • Subsequent requests use the issued certificate

Security Note: Must be disabled in production to prevent unauthorized app registration.

Certificate Rotation

Planned Feature

Certificate rotation is not currently supported. The RotateCertificate RPC referenced below does not exist in the protobuf definitions (easy.proto/v2/protos/services.proto:161-313).

Current workaround: Applications must re-register to obtain new certificates. This section describes the planned rotation flow for future implementation.

Applications should rotate certificates periodically for security:

Planned Rotation Process:

  1. App generates new key pair and CSR
  2. App calls RotateCertificate RPC with CSR (not yet implemented)
  3. AppServer validates request (signed with old certificate)
  4. AppServer issues new certificate signed by CA
  5. App starts using new certificate
  6. Old certificate can be revoked

Best Practices (for future implementation):

  • Rotate certificates every 90-365 days
  • Store certificates securely (encrypted at rest)
  • Automate rotation process
  • Monitor certificate expiration dates

Certificate Revocation

Certificates can be revoked if compromised:

Revocation Scenarios:

  • Private key suspected to be leaked
  • App uninstalled permanently
  • Security incident requiring rotation
  • Administrative revocation

Future: Certificate Revocation List (CRL) or OCSP support for real-time revocation checking.

Authorization with OpenFGA

Easy AppServer uses OpenFGA for fine-grained, relationship-based access control (ReBAC).

Permission Model

OpenFGA uses a tuple-based authorization model where permissions are expressed as relationships:

Tuple Format: (user, relation, object)

Example Tuples:

(user:alice, viewer, app:todos)           - Alice can view the todos app
(app:billing, accessible_by, route:/api) - Billing app can access route
(role:admin#assignee, write, setting:key) - Admin role can write setting

See Permission Model for detailed authorization architecture.

Permission Types

Route Permissions

Control which apps can access specific API routes:

Tuple: (app:billing, accessible_by, route:/api/invoices)
Check: Can app:billing access route:/api/invoices?

Hook Permissions

Control which apps can trigger or listen to hooks:

Tuple: (app:notifications, trigger, hook:user.created)
Check: Can app:notifications trigger hook:user.created?

Activity Permissions

Control which apps can execute activities:

Tuple: (app:worker, execute, activity:generate-report)
Check: Can app:worker execute activity:generate-report?

Setting Permissions

Control which users/roles can read or write settings:

Tuple: (role:admin#assignee, write, setting:api-key)
Check: Can role:admin write setting:api-key?

Permission Declaration

Applications declare required permissions in their manifest:

Code Reference: easy.proto/v2/protos/manifest.proto:10

import { defineManifest } from '@easy/appserver-sdk';

const manifest = defineManifest()
.name('de.easy-m.billing')
.version('1.0.0')

// Route permissions
.permission('route', '/api/users', 'read')
.permission('route', '/api/invoices', 'write')

// Hook permissions
.permission('hook', 'user.created', 'listen')
.permission('hook', 'invoice.paid', 'trigger')

// Activity permissions
.permission('activity', 'send-email', 'execute')

.build();

During app registration, the platform creates corresponding OpenFGA tuples:

Code Reference: pkg/v2/application/marketplace/marketplace_service.go:43

// Grant permissions declared in manifest
for _, perm := range cmd.RequiredPermissions {
if err := s.tupleManager.GrantPermission(ctx, cmd.Name, perm); err != nil {
return nil, fmt.Errorf("failed to grant permission: %w", err)
}
}

Permission Scopes

Permissions operate at different scopes:

Platform-Level:

  • System configuration access
  • Global resource management
  • Platform administration

Organization-Level (Multi-Tenancy):

  • Tenant-scoped resources
  • Organization-specific data
  • User management within org

Application-Level:

  • App-specific permissions
  • Feature access within app
  • App resource management

Resource-Level:

  • Individual resource permissions
  • Per-user resource access
  • Fine-grained access control

Dangerous Permissions

Some permissions require explicit user consent due to their sensitivity:

Examples:

  • system:configuration:write - System configuration changes
  • user:data:read - Access to user personal data
  • payment:process - Payment processing capability
  • external:api:access - Access to external APIs

Consent Flow:

  1. App declares dangerous permission in manifest
  2. During installation, user is prompted for consent
  3. User reviews permission details and risks
  4. User approves or denies permission
  5. Only granted if user approves

Permission Checks

In Application Services

Services check permissions before executing operations:

// Check if app has permission to access route
hasPermission, err := s.permissionChecker.Check(ctx, auth.PermissionRequest{
User: fmt.Sprintf("app:%s", appName),
Relation: "accessible_by",
Object: fmt.Sprintf("route:%s", routePath),
})

if !hasPermission {
return nil, ErrPermissionDenied
}

In GraphQL Resolvers

Resolvers check user permissions before returning data:

func (r *queryResolver) App(ctx context.Context, name string) (*generated.App, error) {
// Extract user from context
userCtx := getUserFromContext(ctx)

// Check permission
hasPermission, err := r.permissionChecker.Check(ctx, auth.PermissionRequest{
User: fmt.Sprintf("user:%s", userCtx.UserID),
Relation: "viewer",
Object: fmt.Sprintf("app:%s", name),
})

if !hasPermission {
return nil, errors.New("permission denied")
}

return r.marketplaceService.GetApp(ctx, name)
}

Via SDK

Apps can check permissions at runtime:

import { checkPermission } from '@easy/appserver-sdk';

async function approveInvoice(invoiceID) {
// Check if current user can approve invoices
const canApprove = await checkPermission({
user: currentUser.id,
relation: 'approver',
object: `invoice:${invoiceID}`
});

if (!canApprove) {
throw new Error('Permission denied: Cannot approve this invoice');
}

await invoiceService.approve(invoiceID);
}

Integration Flow

User Accessing App Feature

Complete flow showing all authentication and authorization layers:

1. User Authentication:
├─ User has valid Kratos session
└─ Session cookie verified

2. User Authorization:
├─ OpenFGA checks: Can user:alice view app:todos?
└─ Permission granted via tuple: (user:alice, viewer, app:todos)

3. User Action (e.g., create todo):
├─ Frontend calls AppServer GraphQL API
└─ GraphQL mutation: createTodo(title: "Buy milk")

4. App API Call:
├─ AppServer proxies request to app backend
└─ Request routed to /api/todos via intelligent proxy

5. App Authentication:
├─ App backend makes gRPC call to AppServer
├─ Request includes x-app-signature headers
├─ AppServer verifies signature using app's certificate
└─ AppContext created for app:todos

6. App Authorization:
├─ OpenFGA checks: Can app:todos access route:/api/todos?
└─ Permission granted via tuple: (app:todos, accessible_by, route:/api/todos)

7. Resource Authorization:
├─ OpenFGA checks: Can user:alice create todo?
└─ Permission granted via tuple: (user:alice, creator, resource:todos)

8. Action Executed:
└─ All checks pass, todo created successfully

Multi-Layer Security

The platform enforces security at multiple layers:

Request Flow:
├─ Layer 1: User Authentication (Kratos session)
├─ Layer 2: User Authorization (OpenFGA user permissions)
├─ Layer 3: App Authentication (Certificate + Signature)
├─ Layer 4: App Authorization (OpenFGA app permissions)
├─ Layer 5: Resource Authorization (OpenFGA resource permissions)
└─ Layer 6: Business Logic Validation (App-specific rules)

Each layer must pass for the request to succeed, providing defense in depth.

OAuth2 / OIDC

Ory Hydra provides OAuth2 and OpenID Connect flows for third-party integrations.

Use Cases

  • Third-party app integrations - External apps accessing platform APIs
  • API access tokens - Machine-to-machine authentication
  • SSO across external services - Single sign-on for integrated services
  • Mobile app authentication - Native mobile apps using OAuth2

OAuth2 Flows

Authorization Code Flow (for web apps):

1. App redirects user to Hydra authorization endpoint
2. User authenticates with Kratos
3. User grants consent to app
4. Hydra redirects back with authorization code
5. App exchanges code for access token
6. App uses access token to call platform APIs

Client Credentials Flow (for machine-to-machine):

1. App sends client_id and client_secret to Hydra
2. Hydra validates credentials
3. Hydra returns access token
4. App uses access token to call platform APIs

Token Refresh:

1. App's access token expires
2. App sends refresh token to Hydra
3. Hydra validates refresh token
4. Hydra issues new access token
5. App continues making API calls

Hydra manages user consent for third-party apps:

  • Consent Screen: User reviews requested scopes
  • Consent Storage: User consent persisted
  • Consent Revocation: User can revoke access
  • Scope Validation: Hydra enforces granted scopes

API Gateway (Oathkeeper)

Ory Oathkeeper acts as an authentication and authorization proxy.

Request Flow

Client Request:
├─ 1. Request hits Oathkeeper
├─ 2. Oathkeeper validates authentication (session or token)
├─ 3. Oathkeeper enriches headers with user context
├─ 4. Oathkeeper forwards to AppServer
└─ 5. AppServer extracts user from headers

Header Enrichment

Oathkeeper adds headers to authenticated requests:

X-User-ID: 550e8400-e29b-41d4-a716-446655440000
X-User-Email: alice@example.com
X-User-Verified: true

AppServer uses these headers to create UserContext without re-validating the session.

Bypass Rules

Public endpoints bypass authentication:

# Oathkeeper configuration
- id: public-health-endpoint
match:
url: <http|https>://<.*>/health
methods:
- GET
authenticators:
- handler: anonymous

Error Handling

Oathkeeper handles authentication failures:

  • 401 Unauthorized: Invalid or missing credentials
  • 403 Forbidden: Valid credentials but insufficient permissions
  • 302 Redirect: Redirect to login for unauthenticated requests

Configuration

Key environment variables for authentication and authorization:

Kratos Configuration

# Kratos URLs
APPSERVER_KRATOS_PUBLIC_URL=http://localhost:4433
APPSERVER_KRATOS_ADMIN_URL=http://localhost:4434

# Session management
APPSERVER_SESSION_COOKIE_NAME=ory_kratos_session
APPSERVER_SESSION_LIFESPAN=24h
APPSERVER_SESSION_IDLE_TIMEOUT=2h

Hydra Configuration

# Hydra URLs
APPSERVER_HYDRA_PUBLIC_URL=http://localhost:4444
APPSERVER_HYDRA_ADMIN_URL=http://localhost:4445

# OAuth2 settings
APPSERVER_OAUTH2_ACCESS_TOKEN_LIFESPAN=1h
APPSERVER_OAUTH2_REFRESH_TOKEN_LIFESPAN=720h # 30 days

OpenFGA Configuration

# OpenFGA connection
APPSERVER_OPENFGA_API_URL=http://localhost:8090
APPSERVER_OPENFGA_STORE_ID=01HQXYZ...
APPSERVER_OPENFGA_MODEL_ID=01HQABC... # Optional

# Cache settings
APPSERVER_OPENFGA_CACHE_ENABLED=true
APPSERVER_OPENFGA_CACHE_TTL=5m

App Authentication Configuration

# Signature validation
APPSERVER_SIGNATURE_REPLAY_WINDOW=5m
APPSERVER_CLOCK_SKEW_TOLERANCE=30s

# Bootstrap registration (DISABLE IN PRODUCTION)
APPSERVER_AUTH_ALLOW_BOOTSTRAP_REGISTRATION=false

Best Practices

Certificate Management

Storage:

Good:
- Store certificates in encrypted key vault (HashiCorp Vault, AWS KMS)
- Restrict file permissions: chmod 400 private-key.pem
- Use environment variables for certificate paths

Bad:
- Commit certificates to Git
- Store in plain text files
- Share certificates between apps

Rotation:

Recommended Schedule:
- Development: Every 90 days
- Production: Every 180-365 days
- Compromise: Immediately

Automation:
- Set up cert-manager or similar tool
- Monitor expiration dates (alert at 30 days)
- Implement zero-downtime rotation

Permission Granularity

Good:

// Fine-grained permissions
.permission('route', '/api/invoices', 'read')
.permission('route', '/api/invoices', 'write')
.permission('route', '/api/invoices/approve', 'execute')

// Specific resource permissions
.permission('resource', 'invoice:draft', 'edit')
.permission('resource', 'invoice:approved', 'view')

Bad:

// Overly broad permissions
.permission('route', '/api/*', 'read')
.permission('route', '/api/*', 'write')

// No resource-level granularity
.permission('resource', 'invoice:*', 'admin')

Least Privilege Principle

Apply minimal permissions:

// Good: Only what's needed
.permission('hook', 'user.created', 'listen') // Listen only
.permission('activity', 'send-email', 'execute') // Execute specific activity

// Bad: Excessive permissions
.permission('hook', '*', 'trigger') // Can trigger ANY hook
.permission('activity', '*', 'execute') // Can execute ANY activity

Audit Logging

Log all authentication events:

logger.Info("user authenticated",
telemetry.String("user_id", userCtx.UserID.String()),
telemetry.String("email", userCtx.Email),
telemetry.String("session_id", userCtx.SessionID))

logger.Info("app authenticated",
telemetry.String("app_name", appCtx.AppName),
telemetry.String("app_id", appCtx.AppID.String()),
telemetry.Bool("verified", appCtx.Verified))

Log permission checks:

logger.Info("permission check",
telemetry.String("user", request.User),
telemetry.String("relation", request.Relation),
telemetry.String("object", request.Object),
telemetry.Bool("granted", hasPermission))

Session Timeout Configuration

Balance security and UX:

Session Lifespan: 24 hours (absolute)
Idle Timeout: 2 hours (inactivity)

Considerations:
- Longer lifespan: Better UX, lower security
- Shorter timeout: Higher security, more login prompts
- Idle timeout: Automatically log out inactive users

Troubleshooting

Signature Verification Fails

Problem: App authentication fails with "signature verification failed"

Diagnosis:
1. Check app certificate is valid and not expired
2. Verify timestamp is within replay window
3. Confirm message construction matches server expectation
4. Check clock sync between client and server
5. Verify private key matches certificate public key

Solution:
- Regenerate certificate if expired
- Sync system clocks (NTP)
- Review signature generation code
- Check for encoding issues (base64)

Permission Denied Errors

Problem: User/app receives "permission denied" error

Diagnosis:
1. Check OpenFGA for relevant tuples
2. Verify permission is declared in app manifest
3. Confirm user has required role or permission
4. Review permission check logic

Solution:
- Add missing permission tuple in OpenFGA
- Update app manifest with required permissions
- Grant user appropriate role
- Check permission relation type matches

Session Expired

Problem: User session expires unexpectedly

Diagnosis:
1. Check session lifespan configuration
2. Verify idle timeout settings
3. Review Kratos session settings
4. Check for session revocation events

Solution:
- Increase session lifespan if appropriate
- Adjust idle timeout
- Implement session renewal (before expiration)
- Check for security events causing revocation

Bootstrap Registration Not Working

Problem: Cannot register new app in development

Diagnosis:
1. Check APPSERVER_AUTH_ALLOW_BOOTSTRAP_REGISTRATION=true
2. Verify calling /easy.v2.Marketplace/Subscribe
3. Review app name in x-app-name header
4. Check for certificate validation errors

Solution:
- Enable bootstrap registration flag
- Ensure using correct gRPC method
- Verify app name is unique
- Review certificate generation logic

Further Reading