binarybeachio: trusted view — mirror OAuth adapter create-shape (Profile, username, is_email_verified)

Plane's OAuth adapter (apps/api/plane/authentication/adapter/base.py:289-342)
creates User AND Profile when a new identity arrives. My trusted view was
calling User.objects.get_or_create() without the Profile, so the SPA's
/api/users/me/profile/ 404'd and the SPA bounced the user back to /login
in an onboarding loop.

Mirror the adapter's full create-shape: random username (uuid hex),
first/last names from JWT claims, is_password_autoset=True,
is_email_verified=True, random password (so Django's auth hash is non-empty
for break-glass), then Profile.objects.create(user=user). Wrapped in a
transaction so partial creation can't leave the DB inconsistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
binarybeach 2026-05-03 23:55:25 -10:00
parent 13b4de6d82
commit c0cfbb2bdc

View file

@ -38,12 +38,14 @@
import logging
import os
import time
import uuid
from typing import Optional, Tuple
from urllib.parse import urlparse
import jwt as pyjwt
import redis
import requests
from django.db import transaction
from django.http import HttpResponseRedirect, HttpResponseNotFound
from django.views import View
@ -55,7 +57,7 @@ from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow
from plane.db.models import User
from plane.db.models import Profile, User
from plane.settings.redis import redis_instance
from plane.utils.path_validator import get_safe_redirect_url
@ -231,18 +233,34 @@ class TrustedSignInEndpoint(View):
if not email:
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", "TRUSTED_JWT_TOKEN_NO_EMAIL", next_path)
# Find-or-create. Plane's User model uses email as a unique natural key;
# other OAuth providers do the same lookup via the OauthAdapter base.
# We mirror that behavior here without going through OauthAdapter — this
# endpoint is a NEW entry-point, not a fifth OAuth provider.
user, created = User.objects.get_or_create(
email=email,
defaults={
"first_name": claims.get("first_name") or claims.get("given_name") or "",
"last_name": claims.get("last_name") or claims.get("family_name") or "",
"is_password_autoset": True,
},
)
# Find-or-create. We mirror the User-creation shape that
# OauthAdapter.complete_login_or_signup() produces (apps/api/plane/
# authentication/adapter/base.py:289-342) — same field set, same
# required side-effect of creating a Profile row. Skipping the Profile
# would cause Plane's SPA /api/users/me/profile/ to 404 and bounce
# the user back to /login in an onboarding loop.
user = User.objects.filter(email=email).first()
created = user is None
if created:
with transaction.atomic():
user = User(
email=email,
username=uuid.uuid4().hex,
first_name=claims.get("first_name") or claims.get("given_name") or "",
last_name=claims.get("last_name") or claims.get("family_name") or "",
is_password_autoset=True,
is_email_verified=True,
)
# Random password — user signs in via SSO; the password exists
# only so Django's auth machinery has a non-empty hash and the
# break-glass admin pattern can be applied later by ALTER-ing
# this user out of band if needed.
user.set_password(uuid.uuid4().hex)
user.save()
# Profile row is mandatory: every Plane API endpoint that
# touches user state (workspace listings, onboarding) reads
# Profile, and the SPA's /api/users/me/profile/ 404s without it.
Profile.objects.create(user=user)
# Plane's existing post-auth workflow (default workspace, invitations, etc.)
post_user_auth_workflow(user=user, is_signup=created, request=request)