remdb 0.3.0__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.
- rem/__init__.py +2 -0
- rem/agentic/README.md +650 -0
- rem/agentic/__init__.py +39 -0
- rem/agentic/agents/README.md +155 -0
- rem/agentic/agents/__init__.py +8 -0
- rem/agentic/context.py +148 -0
- rem/agentic/context_builder.py +329 -0
- rem/agentic/mcp/__init__.py +0 -0
- rem/agentic/mcp/tool_wrapper.py +107 -0
- rem/agentic/otel/__init__.py +5 -0
- rem/agentic/otel/setup.py +151 -0
- rem/agentic/providers/phoenix.py +674 -0
- rem/agentic/providers/pydantic_ai.py +572 -0
- rem/agentic/query.py +117 -0
- rem/agentic/query_helper.py +89 -0
- rem/agentic/schema.py +396 -0
- rem/agentic/serialization.py +245 -0
- rem/agentic/tools/__init__.py +5 -0
- rem/agentic/tools/rem_tools.py +231 -0
- rem/api/README.md +420 -0
- rem/api/main.py +324 -0
- rem/api/mcp_router/prompts.py +182 -0
- rem/api/mcp_router/resources.py +536 -0
- rem/api/mcp_router/server.py +213 -0
- rem/api/mcp_router/tools.py +584 -0
- rem/api/routers/auth.py +229 -0
- rem/api/routers/chat/__init__.py +5 -0
- rem/api/routers/chat/completions.py +281 -0
- rem/api/routers/chat/json_utils.py +76 -0
- rem/api/routers/chat/models.py +124 -0
- rem/api/routers/chat/streaming.py +185 -0
- rem/auth/README.md +258 -0
- rem/auth/__init__.py +26 -0
- rem/auth/middleware.py +100 -0
- rem/auth/providers/__init__.py +13 -0
- rem/auth/providers/base.py +376 -0
- rem/auth/providers/google.py +163 -0
- rem/auth/providers/microsoft.py +237 -0
- rem/cli/README.md +455 -0
- rem/cli/__init__.py +8 -0
- rem/cli/commands/README.md +126 -0
- rem/cli/commands/__init__.py +3 -0
- rem/cli/commands/ask.py +566 -0
- rem/cli/commands/configure.py +497 -0
- rem/cli/commands/db.py +493 -0
- rem/cli/commands/dreaming.py +324 -0
- rem/cli/commands/experiments.py +1302 -0
- rem/cli/commands/mcp.py +66 -0
- rem/cli/commands/process.py +245 -0
- rem/cli/commands/schema.py +183 -0
- rem/cli/commands/serve.py +106 -0
- rem/cli/dreaming.py +363 -0
- rem/cli/main.py +96 -0
- rem/config.py +237 -0
- rem/mcp_server.py +41 -0
- rem/models/core/__init__.py +49 -0
- rem/models/core/core_model.py +64 -0
- rem/models/core/engram.py +333 -0
- rem/models/core/experiment.py +628 -0
- rem/models/core/inline_edge.py +132 -0
- rem/models/core/rem_query.py +243 -0
- rem/models/entities/__init__.py +43 -0
- rem/models/entities/file.py +57 -0
- rem/models/entities/image_resource.py +88 -0
- rem/models/entities/message.py +35 -0
- rem/models/entities/moment.py +123 -0
- rem/models/entities/ontology.py +191 -0
- rem/models/entities/ontology_config.py +131 -0
- rem/models/entities/resource.py +95 -0
- rem/models/entities/schema.py +87 -0
- rem/models/entities/user.py +85 -0
- rem/py.typed +0 -0
- rem/schemas/README.md +507 -0
- rem/schemas/__init__.py +6 -0
- rem/schemas/agents/README.md +92 -0
- rem/schemas/agents/core/moment-builder.yaml +178 -0
- rem/schemas/agents/core/rem-query-agent.yaml +226 -0
- rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
- rem/schemas/agents/core/simple-assistant.yaml +19 -0
- rem/schemas/agents/core/user-profile-builder.yaml +163 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
- rem/schemas/agents/examples/contract-extractor.yaml +134 -0
- rem/schemas/agents/examples/cv-parser.yaml +263 -0
- rem/schemas/agents/examples/hello-world.yaml +37 -0
- rem/schemas/agents/examples/query.yaml +54 -0
- rem/schemas/agents/examples/simple.yaml +21 -0
- rem/schemas/agents/examples/test.yaml +29 -0
- rem/schemas/agents/rem.yaml +128 -0
- rem/schemas/evaluators/hello-world/default.yaml +77 -0
- rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
- rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
- rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
- rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
- rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
- rem/services/__init__.py +16 -0
- rem/services/audio/INTEGRATION.md +308 -0
- rem/services/audio/README.md +376 -0
- rem/services/audio/__init__.py +15 -0
- rem/services/audio/chunker.py +354 -0
- rem/services/audio/transcriber.py +259 -0
- rem/services/content/README.md +1269 -0
- rem/services/content/__init__.py +5 -0
- rem/services/content/providers.py +806 -0
- rem/services/content/service.py +676 -0
- rem/services/dreaming/README.md +230 -0
- rem/services/dreaming/__init__.py +53 -0
- rem/services/dreaming/affinity_service.py +336 -0
- rem/services/dreaming/moment_service.py +264 -0
- rem/services/dreaming/ontology_service.py +54 -0
- rem/services/dreaming/user_model_service.py +297 -0
- rem/services/dreaming/utils.py +39 -0
- rem/services/embeddings/__init__.py +11 -0
- rem/services/embeddings/api.py +120 -0
- rem/services/embeddings/worker.py +421 -0
- rem/services/fs/README.md +662 -0
- rem/services/fs/__init__.py +62 -0
- rem/services/fs/examples.py +206 -0
- rem/services/fs/examples_paths.py +204 -0
- rem/services/fs/git_provider.py +935 -0
- rem/services/fs/local_provider.py +760 -0
- rem/services/fs/parsing-hooks-examples.md +172 -0
- rem/services/fs/paths.py +276 -0
- rem/services/fs/provider.py +460 -0
- rem/services/fs/s3_provider.py +1042 -0
- rem/services/fs/service.py +186 -0
- rem/services/git/README.md +1075 -0
- rem/services/git/__init__.py +17 -0
- rem/services/git/service.py +469 -0
- rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
- rem/services/phoenix/README.md +453 -0
- rem/services/phoenix/__init__.py +46 -0
- rem/services/phoenix/client.py +686 -0
- rem/services/phoenix/config.py +88 -0
- rem/services/phoenix/prompt_labels.py +477 -0
- rem/services/postgres/README.md +575 -0
- rem/services/postgres/__init__.py +23 -0
- rem/services/postgres/migration_service.py +427 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +232 -0
- rem/services/postgres/register_type.py +352 -0
- rem/services/postgres/repository.py +337 -0
- rem/services/postgres/schema_generator.py +379 -0
- rem/services/postgres/service.py +802 -0
- rem/services/postgres/sql_builder.py +354 -0
- rem/services/rem/README.md +304 -0
- rem/services/rem/__init__.py +23 -0
- rem/services/rem/exceptions.py +71 -0
- rem/services/rem/executor.py +293 -0
- rem/services/rem/parser.py +145 -0
- rem/services/rem/queries.py +196 -0
- rem/services/rem/query.py +371 -0
- rem/services/rem/service.py +527 -0
- rem/services/session/README.md +374 -0
- rem/services/session/__init__.py +6 -0
- rem/services/session/compression.py +360 -0
- rem/services/session/reload.py +77 -0
- rem/settings.py +1235 -0
- rem/sql/002_install_models.sql +1068 -0
- rem/sql/background_indexes.sql +42 -0
- rem/sql/install_models.sql +1038 -0
- rem/sql/migrations/001_install.sql +503 -0
- rem/sql/migrations/002_install_models.sql +1202 -0
- rem/utils/AGENTIC_CHUNKING.md +597 -0
- rem/utils/README.md +583 -0
- rem/utils/__init__.py +43 -0
- rem/utils/agentic_chunking.py +622 -0
- rem/utils/batch_ops.py +343 -0
- rem/utils/chunking.py +108 -0
- rem/utils/clip_embeddings.py +276 -0
- rem/utils/dict_utils.py +98 -0
- rem/utils/embeddings.py +423 -0
- rem/utils/examples/embeddings_example.py +305 -0
- rem/utils/examples/sql_types_example.py +202 -0
- rem/utils/markdown.py +16 -0
- rem/utils/model_helpers.py +236 -0
- rem/utils/schema_loader.py +336 -0
- rem/utils/sql_types.py +348 -0
- rem/utils/user_id.py +81 -0
- rem/utils/vision.py +330 -0
- rem/workers/README.md +506 -0
- rem/workers/__init__.py +5 -0
- rem/workers/dreaming.py +502 -0
- rem/workers/engram_processor.py +312 -0
- rem/workers/sqs_file_processor.py +193 -0
- remdb-0.3.0.dist-info/METADATA +1455 -0
- remdb-0.3.0.dist-info/RECORD +187 -0
- remdb-0.3.0.dist-info/WHEEL +4 -0
- remdb-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""OAuth provider implementations."""
|
|
2
|
+
|
|
3
|
+
from .base import OAuthProvider, OAuthTokens, OAuthUserInfo
|
|
4
|
+
from .google import GoogleOAuthProvider
|
|
5
|
+
from .microsoft import MicrosoftOAuthProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"OAuthProvider",
|
|
9
|
+
"OAuthTokens",
|
|
10
|
+
"OAuthUserInfo",
|
|
11
|
+
"GoogleOAuthProvider",
|
|
12
|
+
"MicrosoftOAuthProvider",
|
|
13
|
+
]
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base OAuth Provider for OAuth 2.1 compliant authentication.
|
|
3
|
+
|
|
4
|
+
OAuth 2.1 Security Best Practices:
|
|
5
|
+
- PKCE (Proof Key for Code Exchange) mandatory for all flows
|
|
6
|
+
- State parameter for CSRF protection
|
|
7
|
+
- Nonce for ID token replay protection (OIDC)
|
|
8
|
+
- No implicit flow (deprecated in OAuth 2.1)
|
|
9
|
+
- Short-lived access tokens with refresh tokens
|
|
10
|
+
- Token validation with JWKS (JSON Web Key Set)
|
|
11
|
+
- Redirect URI exact matching
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
- OAuth 2.1: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11
|
|
15
|
+
- OIDC Core: https://openid.net/specs/openid-connect-core-1_0.html
|
|
16
|
+
- PKCE: https://datatracker.ietf.org/doc/html/rfc7636
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import secrets
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OAuthTokens(BaseModel):
|
|
29
|
+
"""
|
|
30
|
+
OAuth token response.
|
|
31
|
+
|
|
32
|
+
Fields match OAuth 2.1 / OIDC token response spec.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
access_token: str = Field(description="Access token for API requests")
|
|
36
|
+
token_type: str = Field(default="Bearer", description="Token type (always Bearer)")
|
|
37
|
+
expires_in: int = Field(description="Token lifetime in seconds")
|
|
38
|
+
refresh_token: str | None = Field(default=None, description="Refresh token for renewal")
|
|
39
|
+
id_token: str | None = Field(default=None, description="ID token (OIDC only)")
|
|
40
|
+
scope: str | None = Field(default=None, description="Granted scopes")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OAuthUserInfo(BaseModel):
|
|
44
|
+
"""
|
|
45
|
+
Normalized user information from OAuth provider.
|
|
46
|
+
|
|
47
|
+
Maps provider-specific fields to standard fields.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
sub: str = Field(description="Subject (unique user ID from provider)")
|
|
51
|
+
email: str | None = Field(default=None, description="User email")
|
|
52
|
+
email_verified: bool = Field(default=False, description="Email verification status")
|
|
53
|
+
name: str | None = Field(default=None, description="Full name")
|
|
54
|
+
given_name: str | None = Field(default=None, description="First name")
|
|
55
|
+
family_name: str | None = Field(default=None, description="Last name")
|
|
56
|
+
picture: str | None = Field(default=None, description="Profile picture URL")
|
|
57
|
+
locale: str | None = Field(default=None, description="User locale")
|
|
58
|
+
|
|
59
|
+
# Provider-specific metadata
|
|
60
|
+
provider: str = Field(description="OAuth provider (google, microsoft)")
|
|
61
|
+
raw_claims: dict[str, Any] = Field(
|
|
62
|
+
default_factory=dict, description="Raw claims from ID token/userinfo"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OAuthProvider(ABC):
|
|
67
|
+
"""
|
|
68
|
+
Base class for OAuth 2.1 providers.
|
|
69
|
+
|
|
70
|
+
Implements common OAuth flow logic with PKCE.
|
|
71
|
+
Subclasses implement provider-specific endpoints and claim mapping.
|
|
72
|
+
|
|
73
|
+
Design Pattern:
|
|
74
|
+
1. generate_auth_url() - Create authorization URL with PKCE
|
|
75
|
+
2. exchange_code() - Exchange code for tokens using code_verifier
|
|
76
|
+
3. validate_token() - Validate access/ID token with JWKS
|
|
77
|
+
4. get_user_info() - Fetch user info from provider
|
|
78
|
+
5. refresh_token() - Refresh access token using refresh_token
|
|
79
|
+
|
|
80
|
+
OAuth 2.1 Flow:
|
|
81
|
+
1. Client generates code_verifier (random string)
|
|
82
|
+
2. Client creates code_challenge = SHA256(code_verifier)
|
|
83
|
+
3. Client redirects to authorization URL with code_challenge
|
|
84
|
+
4. User authenticates and grants consent
|
|
85
|
+
5. Provider redirects to callback with code
|
|
86
|
+
6. Client exchanges code + code_verifier for tokens
|
|
87
|
+
7. Provider validates code_verifier matches code_challenge
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
client_id: str,
|
|
93
|
+
client_secret: str,
|
|
94
|
+
redirect_uri: str,
|
|
95
|
+
):
|
|
96
|
+
"""
|
|
97
|
+
Initialize OAuth provider.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
client_id: OAuth client ID from provider
|
|
101
|
+
client_secret: OAuth client secret from provider
|
|
102
|
+
redirect_uri: Redirect URI registered with provider
|
|
103
|
+
"""
|
|
104
|
+
self.client_id = client_id
|
|
105
|
+
self.client_secret = client_secret
|
|
106
|
+
self.redirect_uri = redirect_uri
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def authorization_endpoint(self) -> str:
|
|
111
|
+
"""Authorization endpoint URL."""
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def token_endpoint(self) -> str:
|
|
117
|
+
"""Token endpoint URL."""
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def userinfo_endpoint(self) -> str:
|
|
123
|
+
"""Userinfo endpoint URL."""
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def jwks_uri(self) -> str:
|
|
129
|
+
"""JWKS (JSON Web Key Set) URI for token validation."""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
@abstractmethod
|
|
134
|
+
def default_scopes(self) -> list[str]:
|
|
135
|
+
"""Default OAuth scopes for this provider."""
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
@abstractmethod
|
|
139
|
+
def normalize_user_info(self, claims: dict[str, Any]) -> OAuthUserInfo:
|
|
140
|
+
"""
|
|
141
|
+
Normalize provider-specific claims to OAuthUserInfo.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
claims: Raw claims from ID token or userinfo endpoint
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Normalized user information
|
|
148
|
+
"""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def generate_code_verifier() -> str:
|
|
153
|
+
"""
|
|
154
|
+
Generate PKCE code verifier.
|
|
155
|
+
|
|
156
|
+
OAuth 2.1 requires PKCE for all authorization code flows.
|
|
157
|
+
Code verifier is a cryptographically random string (43-128 chars).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Code verifier (base64url encoded random bytes)
|
|
161
|
+
"""
|
|
162
|
+
# Generate 32 random bytes (256 bits of entropy)
|
|
163
|
+
# Base64url encode = 43 characters
|
|
164
|
+
return secrets.token_urlsafe(32)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def generate_code_challenge(code_verifier: str) -> str:
|
|
168
|
+
"""
|
|
169
|
+
Generate PKCE code challenge from verifier.
|
|
170
|
+
|
|
171
|
+
Uses S256 method (SHA-256 hash, base64url encoded).
|
|
172
|
+
Plain method is NOT allowed in OAuth 2.1.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
code_verifier: Code verifier from generate_code_verifier()
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Code challenge (SHA-256 of verifier, base64url encoded)
|
|
179
|
+
"""
|
|
180
|
+
# SHA-256 hash of verifier
|
|
181
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
182
|
+
# Base64url encode (no padding)
|
|
183
|
+
return secrets.token_urlsafe(32)[:43] # Trim to match digest length
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def generate_state() -> str:
|
|
187
|
+
"""
|
|
188
|
+
Generate state parameter for CSRF protection.
|
|
189
|
+
|
|
190
|
+
State is verified on callback to prevent CSRF attacks.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Random state string
|
|
194
|
+
"""
|
|
195
|
+
return secrets.token_urlsafe(32)
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def generate_nonce() -> str:
|
|
199
|
+
"""
|
|
200
|
+
Generate nonce for ID token replay protection (OIDC).
|
|
201
|
+
|
|
202
|
+
Nonce is included in ID token and verified to prevent replay.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Random nonce string
|
|
206
|
+
"""
|
|
207
|
+
return secrets.token_urlsafe(32)
|
|
208
|
+
|
|
209
|
+
def generate_auth_url(
|
|
210
|
+
self,
|
|
211
|
+
state: str,
|
|
212
|
+
code_challenge: str,
|
|
213
|
+
scopes: list[str] | None = None,
|
|
214
|
+
nonce: str | None = None,
|
|
215
|
+
extra_params: dict[str, str] | None = None,
|
|
216
|
+
) -> str:
|
|
217
|
+
"""
|
|
218
|
+
Generate authorization URL for OAuth flow.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
state: CSRF protection state
|
|
222
|
+
code_challenge: PKCE code challenge
|
|
223
|
+
scopes: OAuth scopes (uses default_scopes if None)
|
|
224
|
+
nonce: OIDC nonce for ID token replay protection
|
|
225
|
+
extra_params: Provider-specific parameters
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Authorization URL to redirect user to
|
|
229
|
+
"""
|
|
230
|
+
scopes = scopes or self.default_scopes
|
|
231
|
+
|
|
232
|
+
params: dict[str, str] = {
|
|
233
|
+
"client_id": self.client_id,
|
|
234
|
+
"response_type": "code", # Authorization code flow (OAuth 2.1)
|
|
235
|
+
"redirect_uri": self.redirect_uri,
|
|
236
|
+
"scope": " ".join(scopes),
|
|
237
|
+
"state": state,
|
|
238
|
+
"code_challenge": code_challenge,
|
|
239
|
+
"code_challenge_method": "S256", # SHA-256 (required by OAuth 2.1)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if nonce:
|
|
243
|
+
params["nonce"] = nonce
|
|
244
|
+
|
|
245
|
+
if extra_params:
|
|
246
|
+
params.update(extra_params)
|
|
247
|
+
|
|
248
|
+
# Build query string
|
|
249
|
+
query = "&".join(f"{k}={v}" for k, v in params.items())
|
|
250
|
+
return f"{self.authorization_endpoint}?{query}"
|
|
251
|
+
|
|
252
|
+
async def exchange_code(
|
|
253
|
+
self,
|
|
254
|
+
code: str,
|
|
255
|
+
code_verifier: str,
|
|
256
|
+
) -> OAuthTokens:
|
|
257
|
+
"""
|
|
258
|
+
Exchange authorization code for tokens.
|
|
259
|
+
|
|
260
|
+
Uses PKCE code_verifier to prove authorization request ownership.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
code: Authorization code from callback
|
|
264
|
+
code_verifier: PKCE code verifier from session
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
OAuth tokens (access, refresh, ID token)
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
httpx.HTTPStatusError: If token exchange fails
|
|
271
|
+
"""
|
|
272
|
+
async with httpx.AsyncClient() as client:
|
|
273
|
+
response = await client.post(
|
|
274
|
+
self.token_endpoint,
|
|
275
|
+
data={
|
|
276
|
+
"grant_type": "authorization_code",
|
|
277
|
+
"code": code,
|
|
278
|
+
"redirect_uri": self.redirect_uri,
|
|
279
|
+
"client_id": self.client_id,
|
|
280
|
+
"client_secret": self.client_secret,
|
|
281
|
+
"code_verifier": code_verifier, # PKCE verification
|
|
282
|
+
},
|
|
283
|
+
headers={"Accept": "application/json"},
|
|
284
|
+
)
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
return OAuthTokens(**response.json())
|
|
287
|
+
|
|
288
|
+
async def refresh_access_token(
|
|
289
|
+
self,
|
|
290
|
+
refresh_token: str,
|
|
291
|
+
) -> OAuthTokens:
|
|
292
|
+
"""
|
|
293
|
+
Refresh access token using refresh token.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
refresh_token: Refresh token from initial token exchange
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
New OAuth tokens
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
httpx.HTTPStatusError: If refresh fails
|
|
303
|
+
"""
|
|
304
|
+
async with httpx.AsyncClient() as client:
|
|
305
|
+
response = await client.post(
|
|
306
|
+
self.token_endpoint,
|
|
307
|
+
data={
|
|
308
|
+
"grant_type": "refresh_token",
|
|
309
|
+
"refresh_token": refresh_token,
|
|
310
|
+
"client_id": self.client_id,
|
|
311
|
+
"client_secret": self.client_secret,
|
|
312
|
+
},
|
|
313
|
+
headers={"Accept": "application/json"},
|
|
314
|
+
)
|
|
315
|
+
response.raise_for_status()
|
|
316
|
+
return OAuthTokens(**response.json())
|
|
317
|
+
|
|
318
|
+
async def get_user_info(
|
|
319
|
+
self,
|
|
320
|
+
access_token: str,
|
|
321
|
+
) -> OAuthUserInfo:
|
|
322
|
+
"""
|
|
323
|
+
Fetch user information from userinfo endpoint.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
access_token: Access token from token exchange
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Normalized user information
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
httpx.HTTPStatusError: If userinfo request fails
|
|
333
|
+
"""
|
|
334
|
+
async with httpx.AsyncClient() as client:
|
|
335
|
+
response = await client.get(
|
|
336
|
+
self.userinfo_endpoint,
|
|
337
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
338
|
+
)
|
|
339
|
+
response.raise_for_status()
|
|
340
|
+
claims = response.json()
|
|
341
|
+
return self.normalize_user_info(claims)
|
|
342
|
+
|
|
343
|
+
async def validate_id_token(
|
|
344
|
+
self,
|
|
345
|
+
id_token: str,
|
|
346
|
+
nonce: str | None = None,
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
"""
|
|
349
|
+
Validate ID token signature and claims.
|
|
350
|
+
|
|
351
|
+
OAuth 2.1 + OIDC requires:
|
|
352
|
+
- Signature validation with JWKS
|
|
353
|
+
- Issuer validation
|
|
354
|
+
- Audience validation (client_id)
|
|
355
|
+
- Expiration validation
|
|
356
|
+
- Nonce validation (if provided)
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
id_token: JWT ID token from token response
|
|
360
|
+
nonce: Expected nonce value from session
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Validated claims from ID token
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
ValueError: If token validation fails
|
|
367
|
+
|
|
368
|
+
TODO: Implement with python-jose or PyJWT
|
|
369
|
+
"""
|
|
370
|
+
# TODO: Implement JWT validation
|
|
371
|
+
# 1. Fetch JWKS from jwks_uri
|
|
372
|
+
# 2. Decode JWT header to get kid (key ID)
|
|
373
|
+
# 3. Find matching key in JWKS
|
|
374
|
+
# 4. Verify signature with public key
|
|
375
|
+
# 5. Validate claims (iss, aud, exp, nonce)
|
|
376
|
+
raise NotImplementedError("ID token validation not implemented")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google OAuth Provider.
|
|
3
|
+
|
|
4
|
+
Implements OAuth 2.1 / OIDC for Google Sign-In.
|
|
5
|
+
|
|
6
|
+
Configuration:
|
|
7
|
+
1. Create OAuth 2.0 credentials at https://console.cloud.google.com/apis/credentials
|
|
8
|
+
2. Set authorized redirect URI: http://localhost:8000/api/auth/callback (dev)
|
|
9
|
+
3. Enable Google+ API for userinfo access
|
|
10
|
+
4. Set environment variables:
|
|
11
|
+
- AUTH__GOOGLE__CLIENT_ID
|
|
12
|
+
- AUTH__GOOGLE__CLIENT_SECRET
|
|
13
|
+
- AUTH__GOOGLE__REDIRECT_URI
|
|
14
|
+
|
|
15
|
+
Google-specific features:
|
|
16
|
+
- Hosted domain restriction (hd parameter for Google Workspace)
|
|
17
|
+
- Incremental authorization
|
|
18
|
+
- Offline access for refresh tokens
|
|
19
|
+
|
|
20
|
+
References:
|
|
21
|
+
- Google OAuth 2.0: https://developers.google.com/identity/protocols/oauth2
|
|
22
|
+
- Google OIDC: https://developers.google.com/identity/openid-connect/openid-connect
|
|
23
|
+
- Scopes: https://developers.google.com/identity/protocols/oauth2/scopes
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .base import OAuthProvider, OAuthUserInfo
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GoogleOAuthProvider(OAuthProvider):
|
|
32
|
+
"""
|
|
33
|
+
Google OAuth 2.1 / OIDC provider.
|
|
34
|
+
|
|
35
|
+
Uses Google's OIDC endpoints for authentication.
|
|
36
|
+
Supports both online (access token only) and offline (refresh token) access.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Google OIDC discovery endpoint:
|
|
40
|
+
# https://accounts.google.com/.well-known/openid-configuration
|
|
41
|
+
AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
42
|
+
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
|
43
|
+
USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"
|
|
44
|
+
JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs"
|
|
45
|
+
|
|
46
|
+
# Google OAuth scopes
|
|
47
|
+
# openid: Required for OIDC
|
|
48
|
+
# email: User email address
|
|
49
|
+
# profile: User profile information (name, picture)
|
|
50
|
+
DEFAULT_SCOPES = [
|
|
51
|
+
"openid",
|
|
52
|
+
"email",
|
|
53
|
+
"profile",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def authorization_endpoint(self) -> str:
|
|
58
|
+
"""Google authorization endpoint."""
|
|
59
|
+
return self.AUTHORIZATION_ENDPOINT
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def token_endpoint(self) -> str:
|
|
63
|
+
"""Google token endpoint."""
|
|
64
|
+
return self.TOKEN_ENDPOINT
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def userinfo_endpoint(self) -> str:
|
|
68
|
+
"""Google userinfo endpoint."""
|
|
69
|
+
return self.USERINFO_ENDPOINT
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def jwks_uri(self) -> str:
|
|
73
|
+
"""Google JWKS URI for token validation."""
|
|
74
|
+
return self.JWKS_URI
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def default_scopes(self) -> list[str]:
|
|
78
|
+
"""Default scopes for Google OAuth."""
|
|
79
|
+
return self.DEFAULT_SCOPES.copy()
|
|
80
|
+
|
|
81
|
+
def normalize_user_info(self, claims: dict[str, Any]) -> OAuthUserInfo:
|
|
82
|
+
"""
|
|
83
|
+
Normalize Google OIDC claims to OAuthUserInfo.
|
|
84
|
+
|
|
85
|
+
Google OIDC claims:
|
|
86
|
+
- sub: Unique user ID (stable identifier)
|
|
87
|
+
- email: User email address
|
|
88
|
+
- email_verified: Email verification status
|
|
89
|
+
- name: Full name
|
|
90
|
+
- given_name: First name
|
|
91
|
+
- family_name: Last name
|
|
92
|
+
- picture: Profile picture URL
|
|
93
|
+
- locale: User locale (e.g., "en")
|
|
94
|
+
- hd: Hosted domain (for Google Workspace accounts)
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
claims: Raw claims from ID token or userinfo endpoint
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Normalized user information
|
|
101
|
+
"""
|
|
102
|
+
return OAuthUserInfo(
|
|
103
|
+
sub=claims["sub"],
|
|
104
|
+
email=claims.get("email"),
|
|
105
|
+
email_verified=claims.get("email_verified", False),
|
|
106
|
+
name=claims.get("name"),
|
|
107
|
+
given_name=claims.get("given_name"),
|
|
108
|
+
family_name=claims.get("family_name"),
|
|
109
|
+
picture=claims.get("picture"),
|
|
110
|
+
locale=claims.get("locale"),
|
|
111
|
+
provider="google",
|
|
112
|
+
raw_claims=claims,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def generate_auth_url_with_hosted_domain(
|
|
116
|
+
self,
|
|
117
|
+
state: str,
|
|
118
|
+
code_challenge: str,
|
|
119
|
+
hosted_domain: str | None = None,
|
|
120
|
+
access_type: str = "online",
|
|
121
|
+
scopes: list[str] | None = None,
|
|
122
|
+
nonce: str | None = None,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Generate authorization URL with Google-specific parameters.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
state: CSRF protection state
|
|
129
|
+
code_challenge: PKCE code challenge
|
|
130
|
+
hosted_domain: Restrict to Google Workspace domain (e.g., "example.com")
|
|
131
|
+
access_type: "online" (access token only) or "offline" (refresh token)
|
|
132
|
+
scopes: OAuth scopes (uses default_scopes if None)
|
|
133
|
+
nonce: OIDC nonce for ID token replay protection
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Authorization URL
|
|
137
|
+
|
|
138
|
+
Google-specific parameters:
|
|
139
|
+
- hd: Hosted domain restriction (Google Workspace only)
|
|
140
|
+
- access_type: online (default) or offline (for refresh tokens)
|
|
141
|
+
- prompt: consent (force consent screen), select_account (account picker)
|
|
142
|
+
- include_granted_scopes: true (incremental authorization)
|
|
143
|
+
"""
|
|
144
|
+
extra_params: dict[str, str] = {
|
|
145
|
+
"access_type": access_type,
|
|
146
|
+
"include_granted_scopes": "true", # Incremental authorization
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Hosted domain restriction (Google Workspace)
|
|
150
|
+
if hosted_domain:
|
|
151
|
+
extra_params["hd"] = hosted_domain
|
|
152
|
+
|
|
153
|
+
# Force consent screen to get refresh token
|
|
154
|
+
if access_type == "offline":
|
|
155
|
+
extra_params["prompt"] = "consent"
|
|
156
|
+
|
|
157
|
+
return self.generate_auth_url(
|
|
158
|
+
state=state,
|
|
159
|
+
code_challenge=code_challenge,
|
|
160
|
+
scopes=scopes,
|
|
161
|
+
nonce=nonce,
|
|
162
|
+
extra_params=extra_params,
|
|
163
|
+
)
|