-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
RFC: Session Security Enhancements
Phase: 1 — Security Hardening & Enterprise Foundation
Priority: P1 — High
Estimated Effort: Medium
Depends on: Audit Logs
Problem Statement
Authorizer's session management lacks enterprise security features: no session limits per user, no device tracking, no session listing/revocation API, and no admin impersonation. Users cannot see or manage their active sessions. The current Session schema only tracks user_id, user_agent, and ip with no device fingerprinting.
Current Architecture Context
- Session schema (
internal/storage/schemas/session.go):ID,UserID,UserAgent,IP,CreatedAt,UpdatedAt - Memory store (
internal/memory_store/) handles session tokens with keys like{loginMethod}:{userID} - Session tokens are AES-encrypted using
ClientSecretas encryption key - Token types: access (30min), refresh (1yr), session (1yr)
- Memory store implementations: Redis, DB-backed, in-memory
DeleteSessiononly deletes byuserId— no per-session deletion- No device tracking or fingerprinting
Proposed Solution
1. Enhanced Session Schema
Update internal/storage/schemas/session.go:
type Session struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
UserID string `json:"user_id" gorm:"type:char(36);index"`
UserAgent string `json:"user_agent" gorm:"type:text"`
IP string `json:"ip" gorm:"type:varchar(45)"`
DeviceHash string `json:"device_hash" gorm:"type:varchar(64);index"` // SHA-256 of device fingerprint
DeviceName string `json:"device_name" gorm:"type:varchar(256)"` // human-readable: "Chrome on macOS"
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"` // password, otp, social, magic_link
Country string `json:"country" gorm:"type:varchar(100)"` // from IP geolocation (if available)
IsActive bool `json:"is_active" gorm:"type:bool;default:true"`
LastActiveAt int64 `json:"last_active_at"`
ExpiresAt int64 `json:"expires_at"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"`
}Device hash computation (server-side from available signals):
func ComputeDeviceHash(userAgent string, acceptLanguage string, ip string) string {
// Combine stable signals (user agent + accept-language)
// IP excluded from hash since it changes, but used for geo
data := userAgent + "|" + acceptLanguage
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}Parse user agent into human-readable device name using mssola/useragent or similar:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)..." → "Chrome 120 on macOS"
2. Session Limits
Configurable max sessions per user via --max-sessions-per-user=5:
On login, before creating a new session:
activeSessions, _ := store.ListSessionsByUserID(ctx, userID)
if len(activeSessions) >= cfg.MaxSessionsPerUser {
// Evict oldest session (FIFO)
oldestSession := activeSessions[len(activeSessions)-1]
store.DeleteSessionByID(ctx, oldestSession.ID)
memoryStore.DeleteUserSession(userID, oldestSession.ID)
// Audit log: session.evicted
}New storage methods:
ListSessionsByUserID(ctx context.Context, userID string) ([]*schemas.Session, error)
GetSessionByID(ctx context.Context, sessionID string) (*schemas.Session, error)
DeleteSessionByID(ctx context.Context, sessionID string) error
CountSessionsByUserID(ctx context.Context, userID string) (int64, error)
UpdateSession(ctx context.Context, session *schemas.Session) error3. Session Listing & Remote Revocation
GraphQL API (user-facing — users manage their own sessions):
type Session {
id: ID!
device_name: String
ip: String
login_method: String
country: String
is_current: Boolean! # true if this is the requesting session
last_active_at: Int64
created_at: Int64!
}
type Query {
sessions: [Session!]! # List current user's active sessions
}
type Mutation {
revoke_session(id: ID!): Response! # Revoke a specific session
revoke_all_sessions: Response! # Revoke all except current
}Admin GraphQL API:
type Query {
_user_sessions(user_id: ID!): [Session!]! # List any user's sessions
}
type Mutation {
_revoke_session(session_id: ID!): Response! # Admin revoke any session
_revoke_all_user_sessions(user_id: ID!): Response!
}Revocation flow:
- Mark session as
is_active = falsein DB - Delete session tokens from memory store
- On next request with revoked session cookie, user gets 401 → redirected to login
- Audit log:
session.terminated
4. Unrecognized Device Notifications
When a login occurs from a new device hash not seen before for that user:
existingSessions, _ := store.ListSessionsByUserID(ctx, userID)
isKnownDevice := false
for _, s := range existingSessions {
if s.DeviceHash == newDeviceHash {
isKnownDevice = true
break
}
}
if !isKnownDevice && cfg.IsEmailServiceEnabled {
// Send "new device login" email
emailProvider.SendNewDeviceAlert(user.Email, NewDeviceAlertData{
DeviceName: deviceName, // "Chrome 120 on macOS"
IPAddress: clientIP,
Location: country, // from IP geo (if available)
Time: time.Now(),
LoginMethod: loginMethod,
})
// Audit log: user.new_device_login
}New email template event: new_device_login — added to internal/constants/ email template events. Default template included.
5. Admin Impersonation
Off by default — --enable-impersonation=false
GraphQL mutation:
type Mutation {
_impersonate_user(
user_id: ID!
reason: String! # Required — logged in audit trail
): AuthResponse!
}Implementation:
func ImpersonateUser(ctx context.Context, params model.ImpersonateUserInput) (*model.AuthResponse, error) {
if !cfg.EnableImpersonation {
return nil, fmt.Errorf("impersonation is disabled")
}
// Generate short-lived tokens (max 60 minutes, not configurable higher)
accessToken := generateToken(user, 60*time.Minute)
// Add impersonation claims to token
claims["impersonator_id"] = adminID
claims["impersonator_email"] = adminEmail
claims["is_impersonated"] = true
// Audit log with full impersonation context
auditProvider.Log(ctx, audit.AuditEvent{
ActorID: adminID,
ActorType: "admin",
Action: "admin.impersonation_started",
ResourceType: "user",
ResourceID: userID,
Metadata: map[string]interface{}{"reason": reason},
})
return &model.AuthResponse{
AccessToken: accessToken,
ExpiresIn: 3600, // 60 min max
User: user,
}, nil
}Safety guardrails:
- No refresh token issued for impersonated sessions
is_impersonatedclaim in token for downstream services to detect- Reason is mandatory and logged
- Maximum 60-minute session, no extension
- Cannot impersonate other admins
CLI Configuration Flags
--max-sessions-per-user=5 # Max concurrent sessions (0 = unlimited)
--enable-new-device-notification=true # Email alert on new device login
--enable-impersonation=false # Admin impersonation (off by default)
Migration Strategy
- Add new columns to
Sessionschema across all DB providers - Add new storage methods for session CRUD operations
- Update login flows to create enhanced sessions
- Add new GraphQL types and resolvers
- Add
new_device_loginemail template - Wire impersonation (disabled by default)
Testing Plan
- Integration tests for session limit enforcement (FIFO eviction)
- Integration tests for session listing and revocation
- Test new device detection triggers email
- Test impersonation generates short-lived tokens with correct claims
- Test impersonation is blocked when disabled
- Test session revocation invalidates tokens in memory store
References
- OWASP Session Management
- Google Account Activity (inspiration for session listing)
- Clerk Session Management