skrift 0.1.0a1__py3-none-any.whl → 0.1.0a3__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.
- skrift/alembic/env.py +2 -1
- skrift/alembic/versions/20260129_add_oauth_accounts.py +134 -0
- skrift/alembic.ini +2 -2
- skrift/asgi.py +19 -11
- skrift/cli.py +22 -13
- skrift/config.py +59 -5
- skrift/controllers/auth.py +168 -22
- skrift/db/models/__init__.py +2 -1
- skrift/db/models/oauth_account.py +37 -0
- skrift/db/models/user.py +5 -5
- skrift/setup/config_writer.py +4 -2
- skrift/setup/controller.py +209 -72
- skrift/setup/providers.py +53 -2
- skrift/setup/state.py +185 -4
- skrift/static/css/style.css +3 -3
- skrift/templates/auth/dummy_login.html +102 -0
- skrift/templates/auth/login.html +14 -0
- skrift/templates/setup/configuring.html +158 -0
- {skrift-0.1.0a1.dist-info → skrift-0.1.0a3.dist-info}/METADATA +3 -1
- {skrift-0.1.0a1.dist-info → skrift-0.1.0a3.dist-info}/RECORD +22 -18
- {skrift-0.1.0a1.dist-info → skrift-0.1.0a3.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a1.dist-info → skrift-0.1.0a3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""OAuth account model for storing multiple OAuth identities per user."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import ForeignKey, String, UniqueConstraint
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
from skrift.db.base import Base
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from skrift.db.models.user import User
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OAuthAccount(Base):
|
|
16
|
+
"""OAuth account model linking OAuth provider identities to users.
|
|
17
|
+
|
|
18
|
+
This allows a single user to have multiple OAuth provider accounts
|
|
19
|
+
linked to their profile, enabling login via different providers.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__tablename__ = "oauth_accounts"
|
|
23
|
+
|
|
24
|
+
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
25
|
+
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
26
|
+
provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
27
|
+
user_id: Mapped[UUID] = mapped_column(
|
|
28
|
+
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
user: Mapped["User"] = relationship("User", back_populates="oauth_accounts")
|
|
32
|
+
|
|
33
|
+
__table_args__ = (
|
|
34
|
+
UniqueConstraint(
|
|
35
|
+
"provider", "provider_account_id", name="uq_oauth_provider_account"
|
|
36
|
+
),
|
|
37
|
+
)
|
skrift/db/models/user.py
CHANGED
|
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
7
7
|
from skrift.db.base import Base
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
|
+
from skrift.db.models.oauth_account import OAuthAccount
|
|
10
11
|
from skrift.db.models.page import Page
|
|
11
12
|
from skrift.db.models.role import Role
|
|
12
13
|
|
|
@@ -16,12 +17,8 @@ class User(Base):
|
|
|
16
17
|
|
|
17
18
|
__tablename__ = "users"
|
|
18
19
|
|
|
19
|
-
# OAuth identifiers
|
|
20
|
-
oauth_provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
21
|
-
oauth_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
|
22
|
-
|
|
23
20
|
# Profile data from OAuth provider
|
|
24
|
-
email: Mapped[str] = mapped_column(String(255), nullable=
|
|
21
|
+
email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
|
25
22
|
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
26
23
|
picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
27
24
|
|
|
@@ -30,6 +27,9 @@ class User(Base):
|
|
|
30
27
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
31
28
|
|
|
32
29
|
# Relationships
|
|
30
|
+
oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
|
|
31
|
+
"OAuthAccount", back_populates="user", cascade="all, delete-orphan"
|
|
32
|
+
)
|
|
33
33
|
pages: Mapped[list["Page"]] = relationship("Page", back_populates="user")
|
|
34
34
|
roles: Mapped[list["Role"]] = relationship(
|
|
35
35
|
"Role", secondary="user_roles", back_populates="users", lazy="selectin"
|
skrift/setup/config_writer.py
CHANGED
|
@@ -7,6 +7,8 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from ruamel.yaml import YAML
|
|
9
9
|
|
|
10
|
+
from skrift.config import get_config_path as _get_config_path
|
|
11
|
+
|
|
10
12
|
# Default app.yaml structure
|
|
11
13
|
DEFAULT_CONFIG = {
|
|
12
14
|
"controllers": [
|
|
@@ -29,8 +31,8 @@ DEFAULT_CONFIG = {
|
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def get_config_path() -> Path:
|
|
32
|
-
"""Get the path to
|
|
33
|
-
return
|
|
34
|
+
"""Get the path to the current environment's config file."""
|
|
35
|
+
return _get_config_path()
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def backup_config() -> Path | None:
|
skrift/setup/controller.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Setup wizard controller for first-time Skrift configuration."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import base64
|
|
4
5
|
import hashlib
|
|
6
|
+
import json
|
|
5
7
|
import secrets
|
|
6
|
-
import
|
|
8
|
+
from collections.abc import AsyncGenerator
|
|
7
9
|
from contextlib import asynccontextmanager
|
|
8
10
|
from datetime import UTC, datetime
|
|
9
|
-
from pathlib import Path
|
|
10
11
|
from urllib.parse import urlencode
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
@@ -16,9 +17,12 @@ from litestar import Controller, Request, get, post
|
|
|
16
17
|
from litestar.exceptions import HTTPException
|
|
17
18
|
from litestar.params import Parameter
|
|
18
19
|
from litestar.response import Redirect, Template as TemplateResponse
|
|
19
|
-
from
|
|
20
|
+
from litestar.response.sse import ServerSentEvent
|
|
21
|
+
from sqlalchemy import select
|
|
20
22
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
23
|
+
from sqlalchemy.orm import selectinload
|
|
21
24
|
|
|
25
|
+
from skrift.db.models.oauth_account import OAuthAccount
|
|
22
26
|
from skrift.db.models.role import Role, user_roles
|
|
23
27
|
from skrift.db.models.user import User
|
|
24
28
|
from skrift.db.services import setting_service
|
|
@@ -32,7 +36,15 @@ from skrift.setup.config_writer import (
|
|
|
32
36
|
update_database_config,
|
|
33
37
|
)
|
|
34
38
|
from skrift.setup.providers import get_all_providers, get_provider_info
|
|
35
|
-
from skrift.setup.state import
|
|
39
|
+
from skrift.setup.state import (
|
|
40
|
+
can_connect_to_database,
|
|
41
|
+
get_database_url_from_yaml,
|
|
42
|
+
get_first_incomplete_step,
|
|
43
|
+
is_auth_configured,
|
|
44
|
+
is_site_configured,
|
|
45
|
+
run_migrations_if_needed,
|
|
46
|
+
reset_migrations_flag,
|
|
47
|
+
)
|
|
36
48
|
|
|
37
49
|
|
|
38
50
|
@asynccontextmanager
|
|
@@ -75,27 +87,32 @@ class SetupController(Controller):
|
|
|
75
87
|
|
|
76
88
|
@get("/")
|
|
77
89
|
async def index(self, request: Request) -> Redirect:
|
|
78
|
-
"""Redirect to
|
|
79
|
-
# Check wizard progress from session
|
|
80
|
-
wizard_step = request.session.get("setup_wizard_step", "database")
|
|
81
|
-
|
|
82
|
-
# If we don't have app.yaml or db isn't configured, start at database
|
|
83
|
-
if not app_yaml_exists():
|
|
84
|
-
return Redirect(path="/setup/database")
|
|
90
|
+
"""Redirect to the first incomplete setup step.
|
|
85
91
|
|
|
92
|
+
Uses smart detection to skip already-configured steps, allowing users
|
|
93
|
+
to resume setup without re-entering existing configuration.
|
|
94
|
+
"""
|
|
95
|
+
# Check if database is configured and connectable
|
|
86
96
|
can_connect, _ = await can_connect_to_database()
|
|
87
|
-
if
|
|
88
|
-
|
|
97
|
+
if can_connect:
|
|
98
|
+
# Database is configured - go through configuring page to run migrations
|
|
99
|
+
return Redirect(path="/setup/configuring")
|
|
89
100
|
|
|
90
|
-
#
|
|
91
|
-
|
|
101
|
+
# Database not configured - go to database step
|
|
102
|
+
request.session["setup_wizard_step"] = "database"
|
|
103
|
+
return Redirect(path="/setup/database")
|
|
92
104
|
|
|
93
105
|
@get("/database")
|
|
94
|
-
async def database_step(self, request: Request) -> TemplateResponse:
|
|
106
|
+
async def database_step(self, request: Request) -> TemplateResponse | Redirect:
|
|
95
107
|
"""Step 1: Database configuration."""
|
|
96
108
|
flash = request.session.pop("flash", None)
|
|
97
109
|
error = request.session.pop("setup_error", None)
|
|
98
110
|
|
|
111
|
+
# If database is already configured and no errors, go to configuring page
|
|
112
|
+
can_connect, _ = await can_connect_to_database()
|
|
113
|
+
if can_connect and not error:
|
|
114
|
+
return Redirect(path="/setup/configuring")
|
|
115
|
+
|
|
99
116
|
# Load current config if exists
|
|
100
117
|
config = load_config()
|
|
101
118
|
db_config = config.get("db", {})
|
|
@@ -167,41 +184,9 @@ class SetupController(Controller):
|
|
|
167
184
|
request.session["setup_error"] = f"Connection failed: {error}"
|
|
168
185
|
return Redirect(path="/setup/database")
|
|
169
186
|
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
["skrift-db", "upgrade", "head"],
|
|
174
|
-
capture_output=True,
|
|
175
|
-
text=True,
|
|
176
|
-
cwd=Path.cwd(),
|
|
177
|
-
timeout=60,
|
|
178
|
-
)
|
|
179
|
-
if result.returncode != 0:
|
|
180
|
-
request.session["setup_error"] = f"Migration failed: {result.stderr}"
|
|
181
|
-
return Redirect(path="/setup/database")
|
|
182
|
-
except subprocess.TimeoutExpired:
|
|
183
|
-
request.session["setup_error"] = "Migration timed out"
|
|
184
|
-
return Redirect(path="/setup/database")
|
|
185
|
-
except FileNotFoundError:
|
|
186
|
-
# skrift-db might not be installed yet, try alembic directly
|
|
187
|
-
try:
|
|
188
|
-
result = subprocess.run(
|
|
189
|
-
["alembic", "upgrade", "head"],
|
|
190
|
-
capture_output=True,
|
|
191
|
-
text=True,
|
|
192
|
-
cwd=Path.cwd(),
|
|
193
|
-
timeout=60,
|
|
194
|
-
)
|
|
195
|
-
if result.returncode != 0:
|
|
196
|
-
request.session["setup_error"] = f"Migration failed: {result.stderr}"
|
|
197
|
-
return Redirect(path="/setup/database")
|
|
198
|
-
except Exception as e:
|
|
199
|
-
request.session["setup_error"] = f"Could not run migrations: {e}"
|
|
200
|
-
return Redirect(path="/setup/database")
|
|
201
|
-
|
|
202
|
-
request.session["setup_wizard_step"] = "auth"
|
|
203
|
-
request.session["flash"] = "Database configured successfully!"
|
|
204
|
-
return Redirect(path="/setup/auth")
|
|
187
|
+
# Connection successful - redirect to configuring page to run migrations
|
|
188
|
+
request.session["setup_wizard_step"] = "configuring"
|
|
189
|
+
return Redirect(path="/setup/configuring")
|
|
205
190
|
|
|
206
191
|
except Exception as e:
|
|
207
192
|
request.session["setup_error"] = str(e)
|
|
@@ -213,12 +198,116 @@ class SetupController(Controller):
|
|
|
213
198
|
request.session["setup_wizard_step"] = "auth"
|
|
214
199
|
return Redirect(path="/setup/auth")
|
|
215
200
|
|
|
201
|
+
@get("/configuring")
|
|
202
|
+
async def configuring_step(self, request: Request) -> TemplateResponse | Redirect:
|
|
203
|
+
"""Database configuration in progress page.
|
|
204
|
+
|
|
205
|
+
Shows a loading spinner while migrations run via SSE.
|
|
206
|
+
"""
|
|
207
|
+
flash = request.session.pop("flash", None)
|
|
208
|
+
error = request.session.pop("setup_error", None)
|
|
209
|
+
|
|
210
|
+
# Verify we can connect to the database first
|
|
211
|
+
can_connect, connection_error = await can_connect_to_database()
|
|
212
|
+
if not can_connect:
|
|
213
|
+
request.session["setup_error"] = f"Cannot connect to database: {connection_error}"
|
|
214
|
+
return Redirect(path="/setup/database")
|
|
215
|
+
|
|
216
|
+
# Reset migrations flag so they run fresh via SSE
|
|
217
|
+
reset_migrations_flag()
|
|
218
|
+
|
|
219
|
+
return TemplateResponse(
|
|
220
|
+
"setup/configuring.html",
|
|
221
|
+
context={
|
|
222
|
+
"flash": flash,
|
|
223
|
+
"error": error,
|
|
224
|
+
"step": 1,
|
|
225
|
+
"total_steps": 4,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@get("/configuring/status")
|
|
230
|
+
async def configuring_status(self, request: Request) -> ServerSentEvent:
|
|
231
|
+
"""SSE endpoint for database configuration status.
|
|
232
|
+
|
|
233
|
+
Streams migration progress and completion status.
|
|
234
|
+
"""
|
|
235
|
+
async def generate_status() -> AsyncGenerator[str, None]:
|
|
236
|
+
# Send initial status
|
|
237
|
+
yield json.dumps({
|
|
238
|
+
"status": "running",
|
|
239
|
+
"message": "Testing database connection...",
|
|
240
|
+
"detail": "",
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await asyncio.sleep(0.5)
|
|
244
|
+
|
|
245
|
+
# Test connection
|
|
246
|
+
can_connect, connection_error = await can_connect_to_database()
|
|
247
|
+
if not can_connect:
|
|
248
|
+
yield json.dumps({
|
|
249
|
+
"status": "error",
|
|
250
|
+
"message": f"Database connection failed: {connection_error}",
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
yield json.dumps({
|
|
255
|
+
"status": "running",
|
|
256
|
+
"message": "Running database migrations...",
|
|
257
|
+
"detail": "This may take a moment",
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await asyncio.sleep(0.3)
|
|
261
|
+
|
|
262
|
+
# Run migrations
|
|
263
|
+
success, error = run_migrations_if_needed()
|
|
264
|
+
|
|
265
|
+
if not success:
|
|
266
|
+
yield json.dumps({
|
|
267
|
+
"status": "error",
|
|
268
|
+
"message": f"Migration failed: {error}",
|
|
269
|
+
})
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
yield json.dumps({
|
|
273
|
+
"status": "running",
|
|
274
|
+
"message": "Verifying database schema...",
|
|
275
|
+
"detail": "",
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
await asyncio.sleep(0.3)
|
|
279
|
+
|
|
280
|
+
# Determine next step
|
|
281
|
+
if is_auth_configured():
|
|
282
|
+
if await is_site_configured():
|
|
283
|
+
next_step = "admin"
|
|
284
|
+
else:
|
|
285
|
+
next_step = "site"
|
|
286
|
+
else:
|
|
287
|
+
next_step = "auth"
|
|
288
|
+
|
|
289
|
+
# All done - include next step
|
|
290
|
+
yield json.dumps({
|
|
291
|
+
"status": "complete",
|
|
292
|
+
"message": "Database configured successfully!",
|
|
293
|
+
"next_step": next_step,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
return ServerSentEvent(generate_status())
|
|
297
|
+
|
|
216
298
|
@get("/auth")
|
|
217
|
-
async def auth_step(self, request: Request) -> TemplateResponse:
|
|
299
|
+
async def auth_step(self, request: Request) -> TemplateResponse | Redirect:
|
|
218
300
|
"""Step 2: Authentication providers."""
|
|
219
301
|
flash = request.session.pop("flash", None)
|
|
220
302
|
error = request.session.pop("setup_error", None)
|
|
221
303
|
|
|
304
|
+
# If auth is already configured and no errors, skip to next step
|
|
305
|
+
if is_auth_configured() and not error:
|
|
306
|
+
next_step = await get_first_incomplete_step()
|
|
307
|
+
if next_step.value != "auth":
|
|
308
|
+
request.session["setup_wizard_step"] = next_step.value
|
|
309
|
+
return Redirect(path=f"/setup/{next_step.value}")
|
|
310
|
+
|
|
222
311
|
# Get current redirect URL from request
|
|
223
312
|
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
|
224
313
|
host = request.headers.get("host", request.url.netloc)
|
|
@@ -291,20 +380,29 @@ class SetupController(Controller):
|
|
|
291
380
|
use_env_vars=use_env_vars,
|
|
292
381
|
)
|
|
293
382
|
|
|
294
|
-
|
|
383
|
+
# Determine next step using smart detection
|
|
384
|
+
next_step = await get_first_incomplete_step()
|
|
385
|
+
request.session["setup_wizard_step"] = next_step.value
|
|
295
386
|
request.session["flash"] = "Authentication configured successfully!"
|
|
296
|
-
return Redirect(path="/setup/
|
|
387
|
+
return Redirect(path=f"/setup/{next_step.value}")
|
|
297
388
|
|
|
298
389
|
except Exception as e:
|
|
299
390
|
request.session["setup_error"] = str(e)
|
|
300
391
|
return Redirect(path="/setup/auth")
|
|
301
392
|
|
|
302
393
|
@get("/site")
|
|
303
|
-
async def site_step(self, request: Request) -> TemplateResponse:
|
|
394
|
+
async def site_step(self, request: Request) -> TemplateResponse | Redirect:
|
|
304
395
|
"""Step 3: Site settings."""
|
|
305
396
|
flash = request.session.pop("flash", None)
|
|
306
397
|
error = request.session.pop("setup_error", None)
|
|
307
398
|
|
|
399
|
+
# If site is already configured and no errors, skip to next step
|
|
400
|
+
if await is_site_configured() and not error:
|
|
401
|
+
next_step = await get_first_incomplete_step()
|
|
402
|
+
if next_step.value != "site":
|
|
403
|
+
request.session["setup_wizard_step"] = next_step.value
|
|
404
|
+
return Redirect(path=f"/setup/{next_step.value}")
|
|
405
|
+
|
|
308
406
|
return TemplateResponse(
|
|
309
407
|
"setup/site.html",
|
|
310
408
|
context={
|
|
@@ -356,9 +454,11 @@ class SetupController(Controller):
|
|
|
356
454
|
# Reload cache
|
|
357
455
|
await setting_service.load_site_settings_cache(db_session)
|
|
358
456
|
|
|
359
|
-
|
|
457
|
+
# Determine next step using smart detection - should be admin at this point
|
|
458
|
+
next_step = await get_first_incomplete_step()
|
|
459
|
+
request.session["setup_wizard_step"] = next_step.value
|
|
360
460
|
request.session["flash"] = "Site settings saved!"
|
|
361
|
-
return Redirect(path="/setup/
|
|
461
|
+
return Redirect(path=f"/setup/{next_step.value}")
|
|
362
462
|
|
|
363
463
|
except Exception as e:
|
|
364
464
|
request.session["setup_error"] = str(e)
|
|
@@ -621,31 +721,68 @@ class SetupAuthController(Controller):
|
|
|
621
721
|
if not oauth_id:
|
|
622
722
|
raise HTTPException(status_code=400, detail="Could not determine user ID")
|
|
623
723
|
|
|
724
|
+
email = user_data["email"]
|
|
725
|
+
|
|
624
726
|
# Create user and mark setup complete
|
|
625
727
|
async with get_setup_db_session() as db_session:
|
|
626
|
-
# Check if
|
|
728
|
+
# Step 1: Check if OAuth account already exists
|
|
627
729
|
result = await db_session.execute(
|
|
628
|
-
select(
|
|
730
|
+
select(OAuthAccount)
|
|
731
|
+
.options(selectinload(OAuthAccount.user))
|
|
732
|
+
.where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
|
|
629
733
|
)
|
|
630
|
-
|
|
734
|
+
oauth_account = result.scalar_one_or_none()
|
|
631
735
|
|
|
632
|
-
if
|
|
736
|
+
if oauth_account:
|
|
737
|
+
# Existing OAuth account - update user profile
|
|
738
|
+
user = oauth_account.user
|
|
633
739
|
user.name = user_data["name"]
|
|
634
740
|
if user_data["picture_url"]:
|
|
635
741
|
user.picture_url = user_data["picture_url"]
|
|
636
742
|
user.last_login_at = datetime.now(UTC)
|
|
743
|
+
if email:
|
|
744
|
+
oauth_account.provider_email = email
|
|
637
745
|
else:
|
|
638
|
-
#
|
|
639
|
-
user =
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
746
|
+
# Step 2: Check if a user with this email already exists
|
|
747
|
+
user = None
|
|
748
|
+
if email:
|
|
749
|
+
result = await db_session.execute(
|
|
750
|
+
select(User).where(User.email == email)
|
|
751
|
+
)
|
|
752
|
+
user = result.scalar_one_or_none()
|
|
753
|
+
|
|
754
|
+
if user:
|
|
755
|
+
# Link new OAuth account to existing user
|
|
756
|
+
oauth_account = OAuthAccount(
|
|
757
|
+
provider=provider,
|
|
758
|
+
provider_account_id=oauth_id,
|
|
759
|
+
provider_email=email,
|
|
760
|
+
user_id=user.id,
|
|
761
|
+
)
|
|
762
|
+
db_session.add(oauth_account)
|
|
763
|
+
# Update user profile
|
|
764
|
+
user.name = user_data["name"]
|
|
765
|
+
if user_data["picture_url"]:
|
|
766
|
+
user.picture_url = user_data["picture_url"]
|
|
767
|
+
user.last_login_at = datetime.now(UTC)
|
|
768
|
+
else:
|
|
769
|
+
# Step 3: Create new user + OAuth account
|
|
770
|
+
user = User(
|
|
771
|
+
email=email,
|
|
772
|
+
name=user_data["name"],
|
|
773
|
+
picture_url=user_data["picture_url"],
|
|
774
|
+
last_login_at=datetime.now(UTC),
|
|
775
|
+
)
|
|
776
|
+
db_session.add(user)
|
|
777
|
+
await db_session.flush()
|
|
778
|
+
|
|
779
|
+
oauth_account = OAuthAccount(
|
|
780
|
+
provider=provider,
|
|
781
|
+
provider_account_id=oauth_id,
|
|
782
|
+
provider_email=email,
|
|
783
|
+
user_id=user.id,
|
|
784
|
+
)
|
|
785
|
+
db_session.add(oauth_account)
|
|
649
786
|
|
|
650
787
|
# Ensure roles are synced (they may not exist if DB was created after server start)
|
|
651
788
|
from skrift.auth import sync_roles_to_database
|
skrift/setup/providers.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
+
DUMMY_PROVIDER_KEY = "dummy"
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
@dataclass
|
|
7
9
|
class OAuthProviderInfo:
|
|
@@ -150,6 +152,17 @@ OAUTH_PROVIDERS = {
|
|
|
150
152
|
""".strip(),
|
|
151
153
|
icon="twitter",
|
|
152
154
|
),
|
|
155
|
+
DUMMY_PROVIDER_KEY: OAuthProviderInfo(
|
|
156
|
+
name="Dummy (Development Only)",
|
|
157
|
+
auth_url="",
|
|
158
|
+
token_url="",
|
|
159
|
+
userinfo_url="",
|
|
160
|
+
scopes=[],
|
|
161
|
+
console_url="",
|
|
162
|
+
fields=[],
|
|
163
|
+
instructions="Development-only provider. DO NOT use in production.",
|
|
164
|
+
icon="dummy",
|
|
165
|
+
),
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
|
|
@@ -159,5 +172,43 @@ def get_provider_info(provider: str) -> OAuthProviderInfo | None:
|
|
|
159
172
|
|
|
160
173
|
|
|
161
174
|
def get_all_providers() -> dict[str, OAuthProviderInfo]:
|
|
162
|
-
"""Get all available OAuth providers."""
|
|
163
|
-
return OAUTH_PROVIDERS.
|
|
175
|
+
"""Get all available OAuth providers (excluding dev-only providers)."""
|
|
176
|
+
return {k: v for k, v in OAUTH_PROVIDERS.items() if k != DUMMY_PROVIDER_KEY}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def validate_no_dummy_auth_in_production() -> None:
|
|
180
|
+
"""Exit process if dummy auth is configured in production."""
|
|
181
|
+
import os
|
|
182
|
+
import signal
|
|
183
|
+
import sys
|
|
184
|
+
|
|
185
|
+
from skrift.config import get_environment, load_raw_app_config
|
|
186
|
+
|
|
187
|
+
if get_environment() != "production":
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
config = load_raw_app_config()
|
|
191
|
+
if config is None:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
providers = config.get("auth", {}).get("providers", {})
|
|
195
|
+
if DUMMY_PROVIDER_KEY in providers:
|
|
196
|
+
# Only print if we haven't already (use env var as cross-process flag)
|
|
197
|
+
if not os.environ.get("_SKRIFT_DUMMY_ERROR_PRINTED"):
|
|
198
|
+
os.environ["_SKRIFT_DUMMY_ERROR_PRINTED"] = "1"
|
|
199
|
+
sys.stderr.write(
|
|
200
|
+
"\n"
|
|
201
|
+
"======================================================================\n"
|
|
202
|
+
"SECURITY ERROR: Dummy auth provider is configured in production.\n"
|
|
203
|
+
"Remove 'dummy' from auth.providers in app.yaml.\n"
|
|
204
|
+
"Server will NOT start.\n"
|
|
205
|
+
"======================================================================\n\n"
|
|
206
|
+
)
|
|
207
|
+
sys.stderr.flush()
|
|
208
|
+
|
|
209
|
+
# Kill parent process (uvicorn reloader) to stop respawning
|
|
210
|
+
try:
|
|
211
|
+
os.kill(os.getppid(), signal.SIGTERM)
|
|
212
|
+
except (ProcessLookupError, PermissionError):
|
|
213
|
+
pass
|
|
214
|
+
os._exit(1)
|