remdb 0.3.114__py3-none-any.whl → 0.3.172__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (83) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/agents/sse_simulator.py +2 -0
  4. rem/agentic/context.py +103 -5
  5. rem/agentic/context_builder.py +36 -9
  6. rem/agentic/mcp/tool_wrapper.py +161 -18
  7. rem/agentic/otel/setup.py +1 -0
  8. rem/agentic/providers/phoenix.py +371 -108
  9. rem/agentic/providers/pydantic_ai.py +172 -30
  10. rem/agentic/schema.py +8 -4
  11. rem/api/deps.py +3 -5
  12. rem/api/main.py +26 -4
  13. rem/api/mcp_router/resources.py +15 -10
  14. rem/api/mcp_router/server.py +11 -3
  15. rem/api/mcp_router/tools.py +418 -4
  16. rem/api/middleware/tracking.py +5 -5
  17. rem/api/routers/admin.py +218 -1
  18. rem/api/routers/auth.py +349 -6
  19. rem/api/routers/chat/completions.py +255 -7
  20. rem/api/routers/chat/models.py +81 -7
  21. rem/api/routers/chat/otel_utils.py +33 -0
  22. rem/api/routers/chat/sse_events.py +17 -1
  23. rem/api/routers/chat/streaming.py +126 -19
  24. rem/api/routers/feedback.py +134 -14
  25. rem/api/routers/messages.py +24 -15
  26. rem/api/routers/query.py +6 -3
  27. rem/auth/__init__.py +13 -3
  28. rem/auth/jwt.py +352 -0
  29. rem/auth/middleware.py +115 -10
  30. rem/auth/providers/__init__.py +4 -1
  31. rem/auth/providers/email.py +215 -0
  32. rem/cli/commands/README.md +42 -0
  33. rem/cli/commands/cluster.py +617 -168
  34. rem/cli/commands/configure.py +4 -7
  35. rem/cli/commands/db.py +66 -22
  36. rem/cli/commands/experiments.py +468 -76
  37. rem/cli/commands/schema.py +6 -5
  38. rem/cli/commands/session.py +336 -0
  39. rem/cli/dreaming.py +2 -2
  40. rem/cli/main.py +2 -0
  41. rem/config.py +8 -1
  42. rem/models/core/experiment.py +58 -14
  43. rem/models/entities/__init__.py +4 -0
  44. rem/models/entities/ontology.py +1 -1
  45. rem/models/entities/ontology_config.py +1 -1
  46. rem/models/entities/subscriber.py +175 -0
  47. rem/models/entities/user.py +1 -0
  48. rem/schemas/agents/core/agent-builder.yaml +235 -0
  49. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  50. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  51. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  52. rem/services/__init__.py +3 -1
  53. rem/services/content/service.py +4 -3
  54. rem/services/email/__init__.py +10 -0
  55. rem/services/email/service.py +513 -0
  56. rem/services/email/templates.py +360 -0
  57. rem/services/phoenix/client.py +59 -18
  58. rem/services/postgres/README.md +38 -0
  59. rem/services/postgres/diff_service.py +127 -6
  60. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  61. rem/services/postgres/repository.py +5 -4
  62. rem/services/postgres/schema_generator.py +205 -4
  63. rem/services/session/compression.py +120 -50
  64. rem/services/session/reload.py +14 -7
  65. rem/services/user_service.py +41 -9
  66. rem/settings.py +442 -23
  67. rem/sql/migrations/001_install.sql +156 -0
  68. rem/sql/migrations/002_install_models.sql +1951 -88
  69. rem/sql/migrations/004_cache_system.sql +548 -0
  70. rem/sql/migrations/005_schema_update.sql +145 -0
  71. rem/utils/README.md +45 -0
  72. rem/utils/__init__.py +18 -0
  73. rem/utils/files.py +157 -1
  74. rem/utils/schema_loader.py +139 -10
  75. rem/utils/sql_paths.py +146 -0
  76. rem/utils/vision.py +1 -1
  77. rem/workers/__init__.py +3 -1
  78. rem/workers/db_listener.py +579 -0
  79. rem/workers/unlogged_maintainer.py +463 -0
  80. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
  81. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
  82. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  83. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
rem/auth/jwt.py ADDED
@@ -0,0 +1,352 @@
1
+ """
2
+ JWT Token Service for REM Authentication.
3
+
4
+ Provides JWT token generation and validation for stateless authentication.
5
+ Uses HS256 algorithm with the session secret for signing.
6
+
7
+ Token Types:
8
+ - Access Token: Short-lived (default 1 hour), used for API authentication
9
+ - Refresh Token: Long-lived (default 7 days), used to obtain new access tokens
10
+
11
+ Token Claims:
12
+ - sub: User ID (UUID string)
13
+ - email: User email
14
+ - name: User display name
15
+ - role: User role (user, admin)
16
+ - tier: User subscription tier
17
+ - roles: List of roles for authorization
18
+ - provider: Auth provider (email, google, microsoft)
19
+ - tenant_id: Tenant identifier for multi-tenancy
20
+ - exp: Expiration timestamp
21
+ - iat: Issued at timestamp
22
+ - type: Token type (access, refresh)
23
+
24
+ Usage:
25
+ from rem.auth.jwt import JWTService
26
+
27
+ jwt_service = JWTService()
28
+
29
+ # Generate tokens after successful authentication
30
+ tokens = jwt_service.create_tokens(user_dict)
31
+ # Returns: {"access_token": "...", "refresh_token": "...", "token_type": "bearer", "expires_in": 3600}
32
+
33
+ # Validate token from Authorization header
34
+ user = jwt_service.verify_token(token)
35
+ # Returns user dict or None if invalid
36
+
37
+ # Refresh access token
38
+ new_tokens = jwt_service.refresh_access_token(refresh_token)
39
+ """
40
+
41
+ import time
42
+ import hmac
43
+ import hashlib
44
+ import base64
45
+ import json
46
+ from datetime import datetime, timezone
47
+ from typing import Optional
48
+
49
+ from loguru import logger
50
+
51
+
52
+ class JWTService:
53
+ """
54
+ JWT token service for authentication.
55
+
56
+ Uses HMAC-SHA256 for signing - simple and secure for single-service deployment.
57
+ For multi-service deployments, consider switching to RS256 with public/private keys.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ secret: str | None = None,
63
+ access_token_expiry_seconds: int = 3600, # 1 hour
64
+ refresh_token_expiry_seconds: int = 604800, # 7 days
65
+ issuer: str = "rem",
66
+ ):
67
+ """
68
+ Initialize JWT service.
69
+
70
+ Args:
71
+ secret: Secret key for signing (uses settings.auth.session_secret if not provided)
72
+ access_token_expiry_seconds: Access token lifetime in seconds
73
+ refresh_token_expiry_seconds: Refresh token lifetime in seconds
74
+ issuer: Token issuer identifier
75
+ """
76
+ if secret:
77
+ self._secret = secret
78
+ else:
79
+ from ..settings import settings
80
+ self._secret = settings.auth.session_secret
81
+
82
+ self._access_expiry = access_token_expiry_seconds
83
+ self._refresh_expiry = refresh_token_expiry_seconds
84
+ self._issuer = issuer
85
+
86
+ def _base64url_encode(self, data: bytes) -> str:
87
+ """Base64url encode without padding."""
88
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
89
+
90
+ def _base64url_decode(self, data: str) -> bytes:
91
+ """Base64url decode with padding restoration."""
92
+ padding = 4 - len(data) % 4
93
+ if padding != 4:
94
+ data += "=" * padding
95
+ return base64.urlsafe_b64decode(data)
96
+
97
+ def _sign(self, message: str) -> str:
98
+ """Create HMAC-SHA256 signature."""
99
+ signature = hmac.new(
100
+ self._secret.encode("utf-8"),
101
+ message.encode("utf-8"),
102
+ hashlib.sha256
103
+ ).digest()
104
+ return self._base64url_encode(signature)
105
+
106
+ def _create_token(self, payload: dict) -> str:
107
+ """
108
+ Create a JWT token.
109
+
110
+ Args:
111
+ payload: Token claims
112
+
113
+ Returns:
114
+ Encoded JWT string
115
+ """
116
+ header = {"alg": "HS256", "typ": "JWT"}
117
+
118
+ header_encoded = self._base64url_encode(json.dumps(header, separators=(",", ":")).encode())
119
+ payload_encoded = self._base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
120
+
121
+ message = f"{header_encoded}.{payload_encoded}"
122
+ signature = self._sign(message)
123
+
124
+ return f"{message}.{signature}"
125
+
126
+ def _verify_signature(self, token: str) -> dict | None:
127
+ """
128
+ Verify token signature and decode payload.
129
+
130
+ Args:
131
+ token: JWT token string
132
+
133
+ Returns:
134
+ Decoded payload dict or None if invalid
135
+ """
136
+ try:
137
+ parts = token.split(".")
138
+ if len(parts) != 3:
139
+ return None
140
+
141
+ header_encoded, payload_encoded, signature = parts
142
+
143
+ # Verify signature
144
+ message = f"{header_encoded}.{payload_encoded}"
145
+ expected_signature = self._sign(message)
146
+
147
+ if not hmac.compare_digest(signature, expected_signature):
148
+ logger.debug("JWT signature verification failed")
149
+ return None
150
+
151
+ # Decode payload
152
+ payload = json.loads(self._base64url_decode(payload_encoded))
153
+ return payload
154
+
155
+ except Exception as e:
156
+ logger.debug(f"JWT decode error: {e}")
157
+ return None
158
+
159
+ def create_tokens(
160
+ self,
161
+ user: dict,
162
+ access_expiry: int | None = None,
163
+ refresh_expiry: int | None = None,
164
+ ) -> dict:
165
+ """
166
+ Create access and refresh tokens for a user.
167
+
168
+ Args:
169
+ user: User dict with id, email, name, role, tier, roles, provider, tenant_id
170
+ access_expiry: Override access token expiry (seconds)
171
+ refresh_expiry: Override refresh token expiry (seconds)
172
+
173
+ Returns:
174
+ Dict with access_token, refresh_token, token_type, expires_in
175
+ """
176
+ now = int(time.time())
177
+ access_exp = access_expiry or self._access_expiry
178
+ refresh_exp = refresh_expiry or self._refresh_expiry
179
+
180
+ # Common claims
181
+ base_claims = {
182
+ "sub": user.get("id", ""),
183
+ "email": user.get("email", ""),
184
+ "name": user.get("name", ""),
185
+ "role": user.get("role"),
186
+ "tier": user.get("tier", "free"),
187
+ "roles": user.get("roles", ["user"]),
188
+ "provider": user.get("provider", "email"),
189
+ "tenant_id": user.get("tenant_id", "default"),
190
+ "iss": self._issuer,
191
+ "iat": now,
192
+ }
193
+
194
+ # Access token
195
+ access_payload = {
196
+ **base_claims,
197
+ "type": "access",
198
+ "exp": now + access_exp,
199
+ }
200
+ access_token = self._create_token(access_payload)
201
+
202
+ # Refresh token (minimal claims for security)
203
+ refresh_payload = {
204
+ "sub": user.get("id", ""),
205
+ "email": user.get("email", ""),
206
+ "type": "refresh",
207
+ "iss": self._issuer,
208
+ "iat": now,
209
+ "exp": now + refresh_exp,
210
+ }
211
+ refresh_token = self._create_token(refresh_payload)
212
+
213
+ return {
214
+ "access_token": access_token,
215
+ "refresh_token": refresh_token,
216
+ "token_type": "bearer",
217
+ "expires_in": access_exp,
218
+ }
219
+
220
+ def verify_token(self, token: str, token_type: str = "access") -> dict | None:
221
+ """
222
+ Verify a token and return user claims.
223
+
224
+ Args:
225
+ token: JWT token string
226
+ token_type: Expected token type ("access" or "refresh")
227
+
228
+ Returns:
229
+ User dict with claims or None if invalid/expired
230
+ """
231
+ payload = self._verify_signature(token)
232
+ if not payload:
233
+ return None
234
+
235
+ # Check token type
236
+ if payload.get("type") != token_type:
237
+ logger.debug(f"Token type mismatch: expected {token_type}, got {payload.get('type')}")
238
+ return None
239
+
240
+ # Check expiration
241
+ exp = payload.get("exp", 0)
242
+ if exp < time.time():
243
+ logger.debug("Token expired")
244
+ return None
245
+
246
+ # Check issuer
247
+ if payload.get("iss") != self._issuer:
248
+ logger.debug(f"Token issuer mismatch: expected {self._issuer}, got {payload.get('iss')}")
249
+ return None
250
+
251
+ # Return user dict (compatible with session user format)
252
+ return {
253
+ "id": payload.get("sub"),
254
+ "email": payload.get("email"),
255
+ "name": payload.get("name"),
256
+ "role": payload.get("role"),
257
+ "tier": payload.get("tier", "free"),
258
+ "roles": payload.get("roles", ["user"]),
259
+ "provider": payload.get("provider", "email"),
260
+ "tenant_id": payload.get("tenant_id", "default"),
261
+ }
262
+
263
+ def refresh_access_token(self, refresh_token: str) -> dict | None:
264
+ """
265
+ Create new access token using refresh token.
266
+
267
+ Args:
268
+ refresh_token: Valid refresh token
269
+
270
+ Returns:
271
+ New token dict or None if refresh token is invalid
272
+ """
273
+ # Verify refresh token
274
+ payload = self._verify_signature(refresh_token)
275
+ if not payload:
276
+ return None
277
+
278
+ if payload.get("type") != "refresh":
279
+ logger.debug("Not a refresh token")
280
+ return None
281
+
282
+ # Check expiration
283
+ exp = payload.get("exp", 0)
284
+ if exp < time.time():
285
+ logger.debug("Refresh token expired")
286
+ return None
287
+
288
+ # Create new access token with minimal info from refresh token
289
+ # In production, you'd look up the full user from database
290
+ user = {
291
+ "id": payload.get("sub"),
292
+ "email": payload.get("email"),
293
+ "name": payload.get("email", "").split("@")[0],
294
+ "provider": "email",
295
+ "tenant_id": "default",
296
+ "tier": "free",
297
+ "roles": ["user"],
298
+ }
299
+
300
+ # Only return new access token, keep same refresh token
301
+ now = int(time.time())
302
+ access_payload = {
303
+ "sub": user["id"],
304
+ "email": user["email"],
305
+ "name": user["name"],
306
+ "role": user.get("role"),
307
+ "tier": user["tier"],
308
+ "roles": user["roles"],
309
+ "provider": user["provider"],
310
+ "tenant_id": user["tenant_id"],
311
+ "type": "access",
312
+ "iss": self._issuer,
313
+ "iat": now,
314
+ "exp": now + self._access_expiry,
315
+ }
316
+
317
+ return {
318
+ "access_token": self._create_token(access_payload),
319
+ "token_type": "bearer",
320
+ "expires_in": self._access_expiry,
321
+ }
322
+
323
+ def decode_without_verification(self, token: str) -> dict | None:
324
+ """
325
+ Decode token without verification (for debugging only).
326
+
327
+ Args:
328
+ token: JWT token string
329
+
330
+ Returns:
331
+ Decoded payload or None
332
+ """
333
+ try:
334
+ parts = token.split(".")
335
+ if len(parts) != 3:
336
+ return None
337
+ payload = json.loads(self._base64url_decode(parts[1]))
338
+ return payload
339
+ except Exception:
340
+ return None
341
+
342
+
343
+ # Singleton instance for convenience
344
+ _jwt_service: Optional[JWTService] = None
345
+
346
+
347
+ def get_jwt_service() -> JWTService:
348
+ """Get or create the JWT service singleton."""
349
+ global _jwt_service
350
+ if _jwt_service is None:
351
+ _jwt_service = JWTService()
352
+ return _jwt_service
rem/auth/middleware.py CHANGED
@@ -1,17 +1,28 @@
1
1
  """
2
- OAuth Authentication Middleware for FastAPI.
2
+ Authentication Middleware for FastAPI.
3
3
 
4
- Protects API endpoints by requiring valid session.
5
- Supports anonymous access with rate limiting when allow_anonymous=True.
4
+ Protects API endpoints by requiring valid authentication.
5
+ Supports multiple auth methods: JWT, API Key, Session, Dev Token.
6
+ Anonymous access with rate limiting when allow_anonymous=True.
6
7
  MCP endpoints are always protected unless explicitly disabled.
7
8
 
8
9
  Design Pattern:
9
- - Check session for user on protected paths
10
- - Check Bearer token for dev token (non-production only)
10
+ - API Key (X-API-Key): Access control guardrail, NOT user identity
11
+ - JWT (Authorization: Bearer): Primary method for user identity
12
+ - Dev token: Non-production testing (starts with "dev_")
13
+ - Session: Backward compatibility for browser-based auth
11
14
  - MCP paths always require authentication (protected service)
12
- - If allow_anonymous=True: Allow unauthenticated requests (marked as ANONYMOUS tier)
13
- - If allow_anonymous=False: Return 401 for API calls, redirect browsers to login
14
- - Exclude auth endpoints and public paths
15
+
16
+ Authentication Flow:
17
+ 1. If API key enabled: Validate X-API-Key header (access gate)
18
+ 2. Check JWT token for user identity (primary)
19
+ 3. Check dev token for testing (non-production only)
20
+ 4. Check session for user (backward compatibility)
21
+ 5. If allow_anonymous=True: Allow as anonymous (rate-limited)
22
+ 6. If allow_anonymous=False: Return 401 / redirect to login
23
+
24
+ IMPORTANT: API key validates ACCESS, JWT identifies USER.
25
+ Both can be required: API key for access + JWT for user identity.
15
26
 
16
27
  Access Modes (configured in settings.auth):
17
28
  - enabled=true, allow_anonymous=true: Auth available, anonymous gets rate-limited access
@@ -20,6 +31,11 @@ Access Modes (configured in settings.auth):
20
31
  - mcp_requires_auth=true (default): MCP always requires login regardless of allow_anonymous
21
32
  - mcp_requires_auth=false: MCP follows normal allow_anonymous rules (dev only)
22
33
 
34
+ API Key Authentication (configured in settings.api):
35
+ - api_key_enabled=true: Require X-API-Key header for access
36
+ - api_key: The secret key to validate against
37
+ - API key is an ACCESS GATE, not user identity - JWT still needed for user
38
+
23
39
  Dev Token Support (non-production only):
24
40
  - GET /api/auth/dev/token returns a Bearer token for test-user
25
41
  - Include as: Authorization: Bearer dev_<signature>
@@ -82,6 +98,67 @@ class AuthMiddleware(BaseHTTPMiddleware):
82
98
  self.mcp_requires_auth = mcp_requires_auth
83
99
  self.mcp_path = mcp_path
84
100
 
101
+ def _check_api_key(self, request: Request) -> dict | None:
102
+ """
103
+ Check for valid X-API-Key header.
104
+
105
+ Returns:
106
+ API key user dict if valid, None otherwise
107
+ """
108
+ # Only check if API key auth is enabled
109
+ if not settings.api.api_key_enabled:
110
+ return None
111
+
112
+ # Check for X-API-Key header
113
+ api_key = request.headers.get("x-api-key")
114
+ if not api_key:
115
+ return None
116
+
117
+ # Validate against configured API key
118
+ if settings.api.api_key and api_key == settings.api.api_key:
119
+ logger.debug("X-API-Key authenticated")
120
+ return {
121
+ "id": "api-key-user",
122
+ "email": "api@rem.local",
123
+ "name": "API Key User",
124
+ "provider": "api-key",
125
+ "tenant_id": "default",
126
+ "tier": "pro", # API key users get full access
127
+ "roles": ["user"],
128
+ }
129
+
130
+ # Invalid API key
131
+ logger.warning("Invalid X-API-Key provided")
132
+ return None
133
+
134
+ def _check_jwt_token(self, request: Request) -> dict | None:
135
+ """
136
+ Check for valid JWT in Authorization header.
137
+
138
+ Returns:
139
+ User dict if valid JWT, None otherwise
140
+ """
141
+ auth_header = request.headers.get("authorization", "")
142
+ if not auth_header.startswith("Bearer "):
143
+ return None
144
+
145
+ token = auth_header[7:] # Strip "Bearer "
146
+
147
+ # Skip dev tokens (handled separately)
148
+ if token.startswith("dev_"):
149
+ return None
150
+
151
+ # Verify JWT token
152
+ from .jwt import get_jwt_service
153
+ jwt_service = get_jwt_service()
154
+ user = jwt_service.verify_token(token)
155
+
156
+ if user:
157
+ logger.debug(f"JWT authenticated: {user.get('email')}")
158
+ return user
159
+
160
+ return None
161
+
85
162
  def _check_dev_token(self, request: Request) -> dict | None:
86
163
  """
87
164
  Check for valid dev token in Authorization header (non-production only).
@@ -105,7 +182,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
105
182
  # Verify dev token
106
183
  from ..api.routers.dev import verify_dev_token
107
184
  if verify_dev_token(token):
108
- logger.debug(f"Dev token authenticated as test-user")
185
+ logger.debug("Dev token authenticated as test-user")
109
186
  return {
110
187
  "id": "test-user",
111
188
  "email": "test@rem.local",
@@ -142,6 +219,34 @@ class AuthMiddleware(BaseHTTPMiddleware):
142
219
  if not is_protected or is_excluded:
143
220
  return await call_next(request)
144
221
 
222
+ # API key validation (access control, not user identity)
223
+ # API key is a guardrail for access - JWT identifies the actual user
224
+ if settings.api.api_key_enabled:
225
+ api_key = request.headers.get("x-api-key")
226
+ if not api_key:
227
+ logger.debug(f"Missing X-API-Key for: {path}")
228
+ return JSONResponse(
229
+ status_code=401,
230
+ content={"detail": "API key required. Include X-API-Key header."},
231
+ headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
232
+ )
233
+ if api_key != settings.api.api_key:
234
+ logger.warning(f"Invalid X-API-Key for: {path}")
235
+ return JSONResponse(
236
+ status_code=401,
237
+ content={"detail": "Invalid API key"},
238
+ headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
239
+ )
240
+ logger.debug("X-API-Key validated for access")
241
+ # API key valid - continue to check JWT for user identity
242
+
243
+ # Check for JWT token in Authorization header (primary user identity)
244
+ jwt_user = self._check_jwt_token(request)
245
+ if jwt_user:
246
+ request.state.user = jwt_user
247
+ request.state.is_anonymous = False
248
+ return await call_next(request)
249
+
145
250
  # Check for dev token (non-production only)
146
251
  dev_user = self._check_dev_token(request)
147
252
  if dev_user:
@@ -149,7 +254,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
149
254
  request.state.is_anonymous = False
150
255
  return await call_next(request)
151
256
 
152
- # Check for valid session
257
+ # Check for valid session (backward compatibility)
153
258
  user = request.session.get("user")
154
259
 
155
260
  if user:
@@ -1,6 +1,7 @@
1
- """OAuth provider implementations."""
1
+ """Authentication provider implementations."""
2
2
 
3
3
  from .base import OAuthProvider, OAuthTokens, OAuthUserInfo
4
+ from .email import EmailAuthProvider, EmailAuthResult
4
5
  from .google import GoogleOAuthProvider
5
6
  from .microsoft import MicrosoftOAuthProvider
6
7
 
@@ -8,6 +9,8 @@ __all__ = [
8
9
  "OAuthProvider",
9
10
  "OAuthTokens",
10
11
  "OAuthUserInfo",
12
+ "EmailAuthProvider",
13
+ "EmailAuthResult",
11
14
  "GoogleOAuthProvider",
12
15
  "MicrosoftOAuthProvider",
13
16
  ]