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 logging
import os import os
import time import time
import uuid
from typing import Optional, Tuple from typing import Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import jwt as pyjwt import jwt as pyjwt
import redis import redis
import requests import requests
from django.db import transaction
from django.http import HttpResponseRedirect, HttpResponseNotFound from django.http import HttpResponseRedirect, HttpResponseNotFound
from django.views import View 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.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow 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.settings.redis import redis_instance
from plane.utils.path_validator import get_safe_redirect_url from plane.utils.path_validator import get_safe_redirect_url
@ -231,18 +233,34 @@ class TrustedSignInEndpoint(View):
if not email: if not email:
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", "TRUSTED_JWT_TOKEN_NO_EMAIL", next_path) 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; # Find-or-create. We mirror the User-creation shape that
# other OAuth providers do the same lookup via the OauthAdapter base. # OauthAdapter.complete_login_or_signup() produces (apps/api/plane/
# We mirror that behavior here without going through OauthAdapter — this # authentication/adapter/base.py:289-342) — same field set, same
# endpoint is a NEW entry-point, not a fifth OAuth provider. # required side-effect of creating a Profile row. Skipping the Profile
user, created = User.objects.get_or_create( # would cause Plane's SPA /api/users/me/profile/ to 404 and bounce
email=email, # the user back to /login in an onboarding loop.
defaults={ user = User.objects.filter(email=email).first()
"first_name": claims.get("first_name") or claims.get("given_name") or "", created = user is None
"last_name": claims.get("last_name") or claims.get("family_name") or "", if created:
"is_password_autoset": True, 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.) # Plane's existing post-auth workflow (default workspace, invitations, etc.)
post_user_auth_workflow(user=user, is_signup=created, request=request) post_user_auth_workflow(user=user, is_signup=created, request=request)