diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index f0ea5fdeda..540f1da2d9 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -322,6 +322,47 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) return } + emailVerificationRequired := h != nil && h.authService != nil && h.authService.IsEmailVerifyEnabled(c.Request.Context()) + forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context()) + if compatEmailUser == nil && !emailVerificationRequired && !forceEmailOnSignup { + if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "", "", "linuxdo") + if err == nil { + if err := applyPendingOAuthBinding( + c.Request.Context(), + h.entClient(), + h.authService, + h.userService, + &dbent.PendingAuthSession{ + Intent: oauthIntentLogin, + ProviderType: identityKey.ProviderType, + ProviderKey: identityKey.ProviderKey, + ProviderSubject: identityKey.ProviderSubject, + ResolvedEmail: email, + UpstreamIdentityClaims: upstreamClaims, + }, + nil, + &user.ID, + true, + false, + ); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", "failed to bind oauth identity", "") + return + } + h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + clearOAuthPendingSessionCookie(c, secureCookie) + clearOAuthPendingBrowserCookie(c, secureCookie) + redirectOAuthTokenPair(c, frontendCallback, tokenPair, redirectTo) + return + } + if !errors.Is(err, service.ErrOAuthInvitationRequired) { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + } if err := h.createLinuxDoOAuthChoicePendingSession( c, identityKey, @@ -332,7 +373,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { upstreamClaims, compatEmail, compatEmailUser, - h.isForceEmailOnThirdPartySignup(c.Request.Context()), + forceEmailOnSignup, ); err != nil { redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") return @@ -744,6 +785,35 @@ func redirectOAuthError(c *gin.Context, frontendCallback string, code string, me redirectWithFragment(c, frontendCallback, fragment) } +func redirectOAuthTokenPair(c *gin.Context, frontendCallback string, tokenPair *service.TokenPair, redirectTo string) { + fragment := url.Values{} + if tokenPair != nil { + fragment.Set("access_token", truncateFragmentValue(tokenPair.AccessToken)) + fragment.Set("refresh_token", truncateFragmentValue(tokenPair.RefreshToken)) + fragment.Set("expires_in", strconv.Itoa(tokenPair.ExpiresIn)) + fragment.Set("token_type", "Bearer") + } + if redirect := strings.TrimSpace(redirectTo); redirect != "" { + originalRedirect := redirect + for range 2 { + decoded, err := url.QueryUnescape(redirect) + if err != nil || decoded == redirect { + break + } + redirect = decoded + } + if redirect != originalRedirect { + if sanitized := sanitizeFrontendRedirectPath(redirect); sanitized != "" { + redirect = sanitized + } else { + redirect = originalRedirect + } + } + fragment.Set("redirect", truncateFragmentValue(redirect)) + } + redirectWithFragment(c, frontendCallback, fragment) +} + func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) { u, err := url.Parse(frontendCallback) if err != nil { diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go index 8b01ab417f..20a56bc8ad 100644 --- a/backend/internal/handler/auth_linuxdo_oauth_test.go +++ b/backend/internal/handler/auth_linuxdo_oauth_test.go @@ -241,8 +241,20 @@ func TestLinuxDoOAuthCallbackAllowsMissingVerifierWhenPKCEDisabled(t *testing.T) handler.LinuxDoOAuthCallback(c) require.Equal(t, http.StatusFound, recorder.Code) - require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location")) - require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)) + location := recorder.Header().Get("Location") + require.Contains(t, location, "/auth/linuxdo/callback#") + require.Contains(t, location, "access_token=") + requireCookieCleared(t, recorder, oauthPendingSessionCookieName) + + identity, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("linuxdo"), + authidentity.ProviderKeyEQ("linuxdo"), + authidentity.ProviderSubjectEQ("compat-subject"), + ). + Only(context.Background()) + require.NoError(t, err) + require.Positive(t, identity.UserID) } func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) { @@ -619,6 +631,82 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite require.Equal(t, "third_party_signup", completion["choice_reason"]) } +func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`)) + case "/userinfo": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"direct-123","username":"linuxdo_direct","name":"Direct Login","avatar_url":"https://cdn.example/direct.png"}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{ + Enabled: true, + ClientID: "linuxdo-client", + ClientSecret: "linuxdo-secret", + AuthorizeURL: upstream.URL + "/authorize", + TokenURL: upstream.URL + "/token", + UserInfoURL: upstream.URL + "/userinfo", + Scopes: "read", + RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback", + FrontendRedirectURL: "/auth/linuxdo/callback", + TokenAuthMethod: "client_secret_post", + UsePKCE: true, + }) + t.Cleanup(func() { _ = client.Close() }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-direct&state=state-direct", nil) + req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-direct")) + req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard")) + req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-direct")) + req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin)) + req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-direct")) + c.Request = req + + handler.LinuxDoOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + location := recorder.Header().Get("Location") + require.Contains(t, location, "/auth/linuxdo/callback#") + require.Contains(t, location, "access_token=") + require.Contains(t, location, "refresh_token=") + fragmentValues := parseOAuthRedirectFragment(t, location) + require.Equal(t, "/dashboard", fragmentValues.Get("redirect")) + requireCookieCleared(t, recorder, oauthPendingSessionCookieName) + requireCookieCleared(t, recorder, oauthPendingBrowserCookieName) + + ctx := context.Background() + userEntity, err := client.User.Query(). + Where(dbuser.EmailEQ("linuxdo-direct-123@linuxdo-connect.invalid")). + Only(ctx) + require.NoError(t, err) + require.Equal(t, "linuxdo_direct", userEntity.Username) + require.Equal(t, "linuxdo", userEntity.SignupSource) + + identity, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("linuxdo"), + authidentity.ProviderKeyEQ("linuxdo"), + authidentity.ProviderSubjectEQ("direct-123"), + ). + Only(ctx) + require.NoError(t, err) + require.Equal(t, userEntity.ID, identity.UserID) + require.Equal(t, "https://cdn.example/direct.png", identity.Metadata["suggested_avatar_url"]) + + sessionCount, err := client.PendingAuthSession.Query().Count(ctx) + require.NoError(t, err) + require.Zero(t, sessionCount) +} + func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue index a566e264e1..112a25cec0 100644 --- a/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue +++ b/frontend/src/components/auth/PendingOAuthCreateAccountForm.vue @@ -16,7 +16,7 @@ :placeholder="t('auth.passwordPlaceholder')" :disabled="isSubmitting" /> -
+
-
- + + maxlength="6" + class="input min-w-0 flex-1" + placeholder="123456" + :disabled="isSubmitting" + />
-

+

{{ t('auth.codeSentSuccess') }}

-

+

{{ t('auth.verificationCodeHint') }}

{ try { const settings = await getPublicSettings() invitationCodeEnabled.value = settings.invitation_code_enabled === true + emailVerifyEnabled.value = settings.email_verify_enabled !== false turnstileEnabled.value = settings.turnstile_enabled === true turnstileSiteKey.value = settings.turnstile_site_key || '' } catch { invitationCodeEnabled.value = false + emailVerifyEnabled.value = true turnstileEnabled.value = false turnstileSiteKey.value = '' } diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index 1e462e2953..d41c1dd7f0 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -85,9 +85,42 @@ describe('PendingOAuthCreateAccountForm', () => { expect(wrapper.text()).toContain('auth.alreadyHaveAccount') }) + it('hides email verification controls when public settings disable email verification', async () => { + getPublicSettings.mockResolvedValue({ + email_verify_enabled: false, + turnstile_enabled: false, + turnstile_site_key: '' + }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'prefill@example.com', + isSubmitting: false + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + await wrapper.get('form').trigger('submit.prevent') + + expect(wrapper.find('[data-testid="linuxdo-create-account-verify-code"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="linuxdo-create-account-send-code"]').exists()).toBe(false) + expect(wrapper.emitted('submit')).toEqual([ + [ + { + email: 'prefill@example.com', + password: 'secret-123', + verifyCode: '' + } + ] + ]) + }) + it('shows and emits invitation code when invitation-only signup is enabled', async () => { getPublicSettings.mockResolvedValue({ invitation_code_enabled: true, + email_verify_enabled: true, turnstile_enabled: false, turnstile_site_key: '' })