Skip to content

Commit dab350e

Browse files
committed
Enhance documentation and add new features: Updated README with new modules including WebAuthn, magic link, and consent management. Expanded ROADMAP with completed security and auth extensions. Introduced OAuth2 scopes in claims and session metadata structure for improved session management.
1 parent 6839dc2 commit dab350e

49 files changed

Lines changed: 3080 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,34 @@ Core modules: `auth`, `tenant`, `permission`, and `storage` — JWT-based authen
1313
## Modules
1414

1515
- `auth`: JWT service, guards, middleware adapters
16-
- `auth/mfa`: 2FA/MFA TOTP (pquerna/otp)
16+
- `auth/mfa`: 2FA/MFA TOTP (pquerna/otp), recovery codes
1717
- `auth/apikey`: API key validation
18+
- `auth/webauthn`: WebAuthn/Passkeys (build with `-tags webauthn`)
19+
- `auth/magiclink`: magic link / email OTP
20+
- `auth/blacklist`: JWT blacklist for immediate revoke
21+
- `auth/ipfilter`: IP allowlist/blocklist
22+
- `auth/oauth2provider`: OAuth2 authorization server
23+
- `auth/scopes`: OAuth2 scope checks in claims
1824
- `auth/tenantsql`: tenant filter helpers
1925
- `tenant`: tenant selection, override policy, lifecycle (create/suspend/delete)
26+
- `tenant/features`: tenant-level feature flags, plan limits
2027
- `permission`: permission check service
28+
- `permission/abac`: ABAC conditions (resource owner, department, environment)
2129
- `social`: social login callback and account linking
2230
- `social/providers/google`: Google OIDC
2331
- `social/providers/github`: GitHub OAuth
32+
- `consent`: OAuth2 consent management
33+
- `saml`: SAML 2.0 SSO
34+
- `ldap`: LDAP/Active Directory auth
35+
- `webhooks`: event webhooks (user.created, session.revoked, etc.)
36+
- `config`: config validation helpers
2437
- `storage`: DB adapter interfaces
2538
- `storage/memory`: in-memory adapters for quick start
2639
- `storage/postgres`: Postgres adapter (SessionStore, RefreshStore)
2740
- `storage/redis`: Redis adapter (SessionStore, RefreshStore)
2841
- `observability/logging`: structured logging (dev/prod)
2942
- `observability/metrics`: Prometheus metrics
43+
- `observability/audit`: audit log search and export (JSON, CSV)
3044
- `observability/tracing`: OpenTelemetry-compatible tracing
3145
- `admin`: optional admin panel (tenants, permissions, sessions) mountable at any URL
3246

@@ -37,6 +51,7 @@ Core modules: `auth`, `tenant`, `permission`, and `storage` — JWT-based authen
3751
- `gin`
3852
- `echo`
3953
- `fiber`
54+
- `graphql` (auth adapter for GraphQL resolvers)
4055

4156
## Goal
4257

ROADMAP.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,37 @@ The library provides a solid foundation. Priority gaps for production:
1414
## P1 - Multi-tenant Runtime
1515

1616
- Tenant lifecycle API (done)
17-
- Tenant-level feature flags and plan/policy binding
17+
- Tenant-level feature flags and plan/policy binding (done)
1818
- Permission wildcard support (done)
19-
- ABAC conditions (resource owner, department, environment)
19+
- ABAC conditions (resource owner, department, environment) (done)
2020

2121
## P2 - DX and Operations
2222

23-
- Config validation package extensions (strict mode)
23+
- Config validation package extensions (strict mode) (done)
2424
- OpenTelemetry tracing hooks (done)
2525
- Prometheus metrics (done)
2626
- Migration-ready SQL adapter packages - postgres (done)
2727
- Versioned changelog + release automation (done)
28+
29+
## P2 - Security & Auth Extensions (done)
30+
31+
- WebAuthn/Passkeys
32+
- Magic link / Email OTP
33+
- MFA recovery codes
34+
- OAuth2 Provider mode
35+
- OAuth2 scopes in claims
36+
37+
## P3 - Operations (done)
38+
39+
- Session metadata (IP, user-agent, last activity)
40+
- Audit log search/export
41+
- IP allowlist/blocklist
42+
- JWT blacklist
43+
- Consent management
44+
45+
## P4 - Enterprise & Integrations (done)
46+
47+
- SAML 2.0
48+
- LDAP/Active Directory
49+
- Webhook events
50+
- GraphQL adapter
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
6+
"github.com/parevo/core/auth"
7+
"github.com/parevo/core/auth/adapters"
8+
"github.com/parevo/core/tenant"
9+
)
10+
11+
// ResolverContext injects auth into GraphQL resolver context.
12+
type ResolverContext struct {
13+
Auth *auth.Service
14+
Tenant *tenant.Service
15+
Opts adapters.Options
16+
}
17+
18+
// NewResolverContext creates a resolver context helper.
19+
func NewResolverContext(authSvc *auth.Service, tenantSvc *tenant.Service, opts adapters.Options) *ResolverContext {
20+
opts = opts.WithDefaults()
21+
return &ResolverContext{Auth: authSvc, Tenant: tenantSvc, Opts: opts}
22+
}
23+
24+
// AuthenticateRequest authenticates the request and returns a context with claims.
25+
// Call this at the start of your GraphQL request handler (e.g. in the middleware before graphql.Execute).
26+
func (r *ResolverContext) AuthenticateRequest(ctx context.Context, authHeader, tenantID string) (context.Context, error) {
27+
policy := r.Opts.OverridePolicy
28+
if policy == nil {
29+
policy = auth.StaticTenantOverridePolicy{Allow: false}
30+
}
31+
newCtx, _, err := r.Auth.AuthenticateContext(ctx, authHeader, tenantID, policy)
32+
return newCtx, err
33+
}
34+
35+
// RequireAuth returns an error if the context has no valid claims.
36+
func RequireAuth(ctx context.Context) error {
37+
_, ok := auth.ClaimsFromContext(ctx)
38+
if !ok {
39+
return auth.ErrUnauthenticated
40+
}
41+
return nil
42+
}
43+
44+
// RequireScope returns an error if claims don't have the required scope.
45+
func RequireScope(ctx context.Context, scope string) error {
46+
claims, ok := auth.ClaimsFromContext(ctx)
47+
if !ok {
48+
return auth.ErrUnauthenticated
49+
}
50+
if !auth.HasScope(claims, scope) {
51+
return auth.ErrForbidden
52+
}
53+
return nil
54+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/parevo/core/auth"
8+
"github.com/parevo/core/auth/adapters"
9+
"github.com/parevo/core/tenant"
10+
)
11+
12+
func TestRequireAuth(t *testing.T) {
13+
ctx := context.Background()
14+
15+
if err := RequireAuth(ctx); err != auth.ErrUnauthenticated {
16+
t.Errorf("empty context should fail: %v", err)
17+
}
18+
19+
ctx = auth.WithClaims(ctx, &auth.Claims{UserID: "u1"})
20+
if err := RequireAuth(ctx); err != nil {
21+
t.Errorf("context with claims should pass: %v", err)
22+
}
23+
}
24+
25+
func TestRequireScope(t *testing.T) {
26+
ctx := context.Background()
27+
28+
if err := RequireScope(ctx, "read:orders"); err != auth.ErrUnauthenticated {
29+
t.Errorf("empty context should fail: %v", err)
30+
}
31+
32+
ctx = auth.WithClaims(ctx, &auth.Claims{UserID: "u1", Scopes: []string{"read:*"}})
33+
if err := RequireScope(ctx, "read:orders"); err != nil {
34+
t.Errorf("matching scope should pass: %v", err)
35+
}
36+
37+
ctx = auth.WithClaims(ctx, &auth.Claims{UserID: "u1", Scopes: []string{"read:orders"}})
38+
if err := RequireScope(ctx, "write:users"); err != auth.ErrForbidden {
39+
t.Errorf("missing scope should fail: %v", err)
40+
}
41+
}
42+
43+
func TestResolverContext_AuthenticateRequest(t *testing.T) {
44+
authSvc, _ := auth.NewService(auth.Config{
45+
Issuer: "test",
46+
Audience: "test",
47+
SecretKey: []byte("super-secret-key-at-least-32-bytes"),
48+
})
49+
rc := NewResolverContext(authSvc, &tenant.Service{}, adapters.Options{})
50+
51+
token, _ := authSvc.IssueAccessToken(auth.Claims{UserID: "u1", TenantID: "t1"})
52+
ctx, err := rc.AuthenticateRequest(context.Background(), "Bearer "+token, "t1")
53+
if err != nil {
54+
t.Fatalf("AuthenticateRequest failed: %v", err)
55+
}
56+
if _, ok := auth.ClaimsFromContext(ctx); !ok {
57+
t.Error("context should have claims")
58+
}
59+
}

auth/blacklist/blacklist.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package blacklist
2+
3+
import (
4+
"context"
5+
"errors"
6+
"time"
7+
)
8+
9+
var ErrTokenBlacklisted = errors.New("token is blacklisted")
10+
11+
// Store persists blacklisted JWT IDs (jti) or token hashes.
12+
type Store interface {
13+
Add(ctx context.Context, jtiOrHash string, expiresAt time.Time) error
14+
Contains(ctx context.Context, jtiOrHash string) (bool, error)
15+
}
16+
17+
// Service provides JWT blacklist for immediate revoke (logout).
18+
type Service struct {
19+
store Store
20+
}
21+
22+
// NewService creates a blacklist service.
23+
func NewService(store Store) *Service {
24+
return &Service{store: store}
25+
}
26+
27+
// Revoke adds a token to the blacklist until it expires.
28+
func (s *Service) Revoke(ctx context.Context, jtiOrHash string, expiresAt time.Time) error {
29+
return s.store.Add(ctx, jtiOrHash, expiresAt)
30+
}
31+
32+
// IsBlacklisted returns true if the token should be rejected.
33+
func (s *Service) IsBlacklisted(ctx context.Context, jtiOrHash string) (bool, error) {
34+
return s.store.Contains(ctx, jtiOrHash)
35+
}
36+
37+
// Check returns ErrTokenBlacklisted if the token is blacklisted.
38+
func (s *Service) Check(ctx context.Context, jtiOrHash string) error {
39+
ok, err := s.IsBlacklisted(ctx, jtiOrHash)
40+
if err != nil {
41+
return err
42+
}
43+
if ok {
44+
return ErrTokenBlacklisted
45+
}
46+
return nil
47+
}

auth/blacklist/blacklist_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package blacklist
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/parevo/core/storage/memory"
9+
)
10+
11+
func TestBlacklist_RevokeAndCheck(t *testing.T) {
12+
store := memory.NewBlacklistStore()
13+
svc := NewService(store)
14+
ctx := context.Background()
15+
16+
expiresAt := time.Now().Add(time.Hour)
17+
if err := svc.Revoke(ctx, "jti-123", expiresAt); err != nil {
18+
t.Fatalf("Revoke failed: %v", err)
19+
}
20+
21+
ok, err := svc.IsBlacklisted(ctx, "jti-123")
22+
if err != nil {
23+
t.Fatalf("IsBlacklisted failed: %v", err)
24+
}
25+
if !ok {
26+
t.Error("token should be blacklisted")
27+
}
28+
29+
if err := svc.Check(ctx, "jti-123"); err != ErrTokenBlacklisted {
30+
t.Errorf("Check should return ErrTokenBlacklisted: %v", err)
31+
}
32+
}
33+
34+
func TestBlacklist_NotBlacklisted(t *testing.T) {
35+
svc := NewService(memory.NewBlacklistStore())
36+
ctx := context.Background()
37+
38+
ok, err := svc.IsBlacklisted(ctx, "unknown")
39+
if err != nil {
40+
t.Fatalf("IsBlacklisted failed: %v", err)
41+
}
42+
if ok {
43+
t.Error("unknown token should not be blacklisted")
44+
}
45+
46+
if err := svc.Check(ctx, "unknown"); err != nil {
47+
t.Errorf("Check should pass for non-blacklisted: %v", err)
48+
}
49+
}

auth/claims.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ type Claims struct {
77
TenantID string `json:"tenant_id"`
88
Roles []string `json:"roles,omitempty"`
99
Permissions []string `json:"permissions,omitempty"`
10+
Scopes []string `json:"scope,omitempty"` // OAuth2 scopes, e.g. "read:orders write:users"
1011
SessionID string `json:"session_id,omitempty"`
1112
TokenType string `json:"typ,omitempty"`
1213
jwt.RegisteredClaims

auth/ipfilter/ipfilter.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package ipfilter
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net"
7+
)
8+
9+
var (
10+
ErrIPBlocked = errors.New("IP address blocked")
11+
ErrIPNotAllowed = errors.New("IP address not in allowlist")
12+
)
13+
14+
// Store provides IP allowlist/blocklist per tenant or global.
15+
type Store interface {
16+
IsAllowed(ctx context.Context, tenantID, ip string) (bool, error)
17+
IsBlocked(ctx context.Context, tenantID, ip string) (bool, error)
18+
}
19+
20+
// Service checks IP against allow/block lists.
21+
type Service struct {
22+
store Store
23+
allowMode bool // true: allowlist (deny by default), false: blocklist (allow by default)
24+
}
25+
26+
// NewService creates an IP filter service.
27+
func NewService(store Store, allowlistMode bool) *Service {
28+
return &Service{store: store, allowMode: allowlistMode}
29+
}
30+
31+
// Allow returns nil if the IP is allowed.
32+
func (s *Service) Allow(ctx context.Context, tenantID, ip string) error {
33+
if ip == "" {
34+
return nil
35+
}
36+
if net.ParseIP(ip) == nil {
37+
return nil
38+
}
39+
blocked, err := s.store.IsBlocked(ctx, tenantID, ip)
40+
if err != nil {
41+
return err
42+
}
43+
if blocked {
44+
return ErrIPBlocked
45+
}
46+
if s.allowMode {
47+
allowed, err := s.store.IsAllowed(ctx, tenantID, ip)
48+
if err != nil {
49+
return err
50+
}
51+
if !allowed {
52+
return ErrIPNotAllowed
53+
}
54+
}
55+
return nil
56+
}

0 commit comments

Comments
 (0)