Fix desktop app OAuth - use window.open() to trigger external browser#14
Conversation
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
- Implement User-Agent mapping in NGINX configuration to append `isMobile=true` for Mattermost desktop/mobile clients. - Update documentation to reflect new detection behavior and its impact on the login flow. - Enhance E2E tests to verify the correct appending of the mobile flag for native clients. - Modify test scripts to include checks for mobile flag in redirects.
|
@copilot десктоп-приложение не реагирует на нововведения, кейклок по-прежнему открывается во внутренней вебвьюхе, а не во внешнем браузере. Изучи репозиторий клиента, чтобы понять, какие именно сигналы должен ему послать плагин чтобы приложение открывало кейклок во внешнем браузере |
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
Проблема была в том, что плагин использовал простой HTTP redirect (302) на URL авторизации Keycloak. Когда десктоп-приложение загружало это в своем webview, редирект следовал внутри webview, и Keycloak открывался там же. Исправление (commit b5c9b33):
Для работы необходимо, чтобы:
Теперь Keycloak открывается во внешнем браузере, где все кнопки работают корректно. |
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
|
@copilot Не помогло. Вот перехват сессии GET /api/v4/config/client?format=old HTTP/1.1 HTTP/1.1 200 OK {"AboutLink":"https://mattermost.com/pl/about-mattermost","AllowDownloadLogs":"true","AndroidAppDownloadLink":"https://mattermost.com/pl/android-app/","AndroidLatestVersion":"","AndroidMinVersion":"","AppDownloadLink":"https://mattermost.com/pl/download-apps","AppsPluginEnabled":"false","AsymmetricSigningPublicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELyfZe/KmKP8V8poNTWJecmSNg5ZYCsmXIhC4NJOhqrZrDGFjOoPpuh2lHkwl9bXhZlHpez2QeEoVgH8Htd7X9w==","BuildDate":"Fri Nov 21 13:42:35 UTC 2025","BuildEnterpriseReady":"false","BuildHash":"317025c411ec8c34381fdd4f137a17c63895a4f2","BuildHashEnterprise":"none","BuildNumber":"19571940986","CWSURL":"","CustomBrandText":"","CustomDescriptionText":"","DefaultClientLocale":"en","DiagnosticId":"q9yohzun8bgxmpxmbjco89rqqy","DiagnosticsEnabled":"true","EmailLoginButtonBorderColor":"#2389D7","EmailLoginButtonColor":"#0000","EmailLoginButtonTextColor":"#2389D7","EnableAskCommunityLink":"true","EnableBotAccountCreation":"false","EnableClientMetrics":"true","EnableComplianceExport":"false","EnableCustomBrand":"false","EnableCustomEmoji":"true","EnableDesktopLandingPage":"true","EnableDiagnostics":"true","EnableFile":"true","EnableGuestAccounts":"false","EnableJoinLeaveMessageByDefault":"true","EnableLdap":"false","EnableMultifactorAuthentication":"false","EnableOpenServer":"false","EnableSaml":"false","EnableSignInWithEmail":"true","EnableSignInWithUsername":"true","EnableSignUpWithEmail":"true","EnableSignUpWithGoogle":"false","EnableSignUpWithOffice365":"false","EnableSignUpWithOpenId":"false","EnableUserCreation":"true","EnableUserStatuses":"true","EnforceMultifactorAuthentication":"false","FeatureFlagAppsEnabled":"false","FeatureFlagAttributeBasedAccessControl":"true","FeatureFlagAutoTranslation":"false","FeatureFlagChannelBookmarks":"true","FeatureFlagCloudAnnualRenewals":"false","FeatureFlagCloudDedicatedExportUI":"false","FeatureFlagCloudIPFiltering":"false","FeatureFlagConsumePostHook":"false","FeatureFlagContentFlagging":"true","FeatureFlagCustomProfileAttributes":"true","FeatureFlagDeprecateCloudFree":"false","FeatureFlagEnableExportDirectDownload":"false","FeatureFlagEnableMattermostEntry":"true","FeatureFlagEnableRemoteClusterService":"false","FeatureFlagEnableSharedChannelsDMs":"false","FeatureFlagEnableSharedChannelsMemberSync":"false","FeatureFlagEnableSharedChannelsPlugins":"true","FeatureFlagEnableSyncAllUsersForRemoteCluster":"false","FeatureFlagExperimentalAuditSettingsSystemConsoleUI":"true","FeatureFlagInteractiveDialogAppsForm":"true","FeatureFlagMobileSSOCodeExchange":"true","FeatureFlagMoveThreadsEnabled":"false","FeatureFlagNormalizeLdapDNs":"false","FeatureFlagNotificationMonitoring":"true","FeatureFlagOnboardingTourTips":"true","FeatureFlagPermalinkPreviews":"false","FeatureFlagStreamlinedMarketplace":"true","FeatureFlagTestBoolFeature":"false","FeatureFlagTestFeature":"off","FeatureFlagWebSocketEventScope":"true","FeatureFlagWysiwygEditor":"false","FileLevel":"INFO","ForgotPasswordLink":"","GuestAccountsEnforceMultifactorAuthentication":"false","HasImageProxy":"false","HelpLink":"https://mattermost.com/pl/help/","HideGuestTags":"false","IosAppDownloadLink":"https://mattermost.com/pl/ios-app/","IosLatestVersion":"","IosMinVersion":"","LdapLoginButtonBorderColor":"","LdapLoginButtonColor":"","LdapLoginButtonTextColor":"","LdapLoginFieldName":"","MobileExternalBrowser":"true","NoAccounts":"false","OpenIdButtonColor":"","OpenIdButtonText":"","PasswordEnableForgotLink":"true","PasswordMinimumLength":"8","PasswordRequireLowercase":"false","PasswordRequireNumber":"false","PasswordRequireSymbol":"false","PasswordRequireUppercase":"false","PluginsEnabled":"true","PrivacyPolicyLink":"https://mattermost.com/pl/privacy-policy/","ReportAProblemLink":"https://mattermost.com/pl/report-a-bug","ReportAProblemMail":"","ReportAProblemType":"default","SamlLoginButtonBorderColor":"","SamlLoginButtonColor":"","SamlLoginButtonText":"","SamlLoginButtonTextColor":"","ServiceEnvironment":"production","SiteName":"Mattermost","SiteURL":"http://mattermost-proxy.127.0.0.1.nip.io:8787","SupportEmail":"","TelemetryId":"q9yohzun8bgxmpxmbjco89rqqy","TermsOfServiceLink":"https://mattermost.com/pl/terms-of-use/","Version":"11.1.1","WebsocketPort":"80","WebsocketSecurePort":"443","WebsocketURL":""} GET /plugins/com.mm.oidc/login?redirect_to=/&isMobile=true HTTP/1.1 HTTP/1.1 302 Found |
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
HTTP трейс показывает HTTP 302 redirect, что означает что плагин не был пересобран после изменений в коде. Код корректный, но старая версия плагина все еще работает. Необходимо:
cd /home/runner/work/mm-oidc/mm-oidc
make server-build
make package # если используется bundled plugin
Если все еще видите 302 redirect после пересборки - проверьте кэш nginx/browser или версию плагина в System Console. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
…rnal browser redirection
- Added deep diagnostics section to the login panel, providing a snapshot of the login context. - Implemented functionality to refresh and copy diagnostics data. - Improved styling for the diagnostics section to enhance user experience. - Introduced new utility functions for sanitizing redirect targets and handling fallback redirects. - Updated the plugin's HTML response to include diagnostics information for better troubleshooting.
There was a problem hiding this comment.
Pull request overview
This PR implements desktop/mobile OAuth authentication support for the Mattermost OIDC plugin by detecting desktop clients via the isMobile query parameter and rendering HTML pages that use window.open() to launch OAuth flows in external browsers instead of embedded webviews. The implementation includes session handoff via the mattermost:// protocol handler, comprehensive diagnostics UI for troubleshooting, and proxy configuration updates to automatically detect desktop clients by User-Agent.
Key Changes:
- Server-side mobile/desktop flow detection and HTML rendering for external browser launch
- New
/completeendpoint for session token handoff to desktop clients - Redirect URL sanitization and fallback mechanisms using cookies and referer headers
- Diagnostics UI in both webapp and server-rendered pages for troubleshooting authentication issues
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| server/plugin.go | Core implementation: mobile detection, external browser redirect rendering, /complete endpoint for token handoff, redirect sanitization functions, and diagnostics collection |
| server/oidc.go | Extended authSession struct with IsMobile flag and RedirectTo field for mobile flow tracking |
| server/plugin_test.go | Added unit tests for extractHost, handleMobileComplete routing, and authSession mobile flag |
| server/go.mod | Moved go-oidc from indirect to direct dependency |
| webapp/src/components/LoginPanel.tsx | Added diagnostics panel with browser/navigator info collection, snapshot refresh, and clipboard copy |
| webapp/src/styles/login-panel.module.css | Styling for new diagnostics UI components |
| deploy/mattermost-proxy/nginx.conf | Added User-Agent detection for Mattermost desktop clients and automatic isMobile flag injection, plus redirect hint cookie logic |
| scripts/dev-bootstrap.sh | Changed plugin installation to always force-replace for reliable development workflow |
| scripts/test-proxy.sh | Added test case for mobile flag detection |
| e2e/tests/proxy-redirect.spec.ts | Added E2E test verifying isMobile flag appended for desktop User-Agents |
| docs/USER_GUIDE.md | Comprehensive desktop app usage section with troubleshooting, configuration, and security notes |
| docs/PROXY_GUIDE.md | Documentation of native desktop/mobile detection in proxy configuration |
| README.md | Updated feature list to include desktop/mobile app support |
| function collectDiagnostics() { | ||
| const snapshot = { | ||
| generated_at: new Date().toISOString(), | ||
| plugin_ready: pluginReady, | ||
| status_variant: statusVariant, | ||
| plugin_id: pluginId, | ||
| issuer_url: issuer, | ||
| redirect_url: redirectURL, | ||
| }; | ||
|
|
||
| if (typeof window !== 'undefined') { | ||
| snapshot.location_href = window.location?.href ?? ''; | ||
| snapshot.location_pathname = window.location?.pathname ?? ''; | ||
| snapshot.location_search = window.location?.search ?? ''; | ||
| snapshot.location_hash = window.location?.hash ?? ''; | ||
| snapshot.location_origin = window.location?.origin ?? ''; | ||
| snapshot.query_params = window.location?.search ? Object.fromEntries(new URLSearchParams(window.location.search)) : {}; | ||
| snapshot.navigator_user_agent = window.navigator?.userAgent ?? ''; | ||
| snapshot.navigator_language = window.navigator?.language ?? ''; | ||
| snapshot.navigator_online = window.navigator?.onLine ?? false; | ||
| snapshot.navigator_platform = window.navigator?.platform ?? ''; | ||
| snapshot.hardware_concurrency = window.navigator?.hardwareConcurrency; | ||
| if (window.navigator && 'deviceMemory' in window.navigator) { | ||
| snapshot.device_memory_gb = window.navigator.deviceMemory; | ||
| } | ||
| snapshot.viewport = { | ||
| inner_width: window.innerWidth, | ||
| inner_height: window.innerHeight, | ||
| outer_width: window.outerWidth, | ||
| outer_height: window.outerHeight, | ||
| }; | ||
| snapshot.screen = window.screen ? { width: window.screen.width, height: window.screen.height, pixel_ratio: window.devicePixelRatio ?? 1 } : null; | ||
| if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { | ||
| snapshot.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; | ||
| } | ||
| try { | ||
| snapshot.local_storage_keys = window.localStorage ? Object.keys(window.localStorage) : []; | ||
| } catch (error) { | ||
| snapshot.local_storage_error = error?.message ?? 'unavailable'; | ||
| } | ||
| try { | ||
| snapshot.session_storage_keys = window.sessionStorage ? Object.keys(window.sessionStorage) : []; | ||
| } catch (error) { | ||
| snapshot.session_storage_error = error?.message ?? 'unavailable'; | ||
| } | ||
| } | ||
|
|
||
| if (typeof document !== 'undefined') { | ||
| snapshot.document_referrer = document.referrer ?? ''; | ||
| snapshot.visibility_state = document.visibilityState ?? ''; | ||
| snapshot.document_has_focus = document.hasFocus ? document.hasFocus() : undefined; | ||
| const cookieNames = parseCookies(); | ||
| snapshot.cookie_names = cookieNames; | ||
| snapshot.cookie_contains_redirect_hint = cookieNames.includes(cookieHintName); | ||
| } | ||
|
|
||
| return snapshot; | ||
| } |
There was a problem hiding this comment.
The diagnostics collection logic is duplicated between the Go server (lines 310-367 in handleLanding, lines 1354-1378 in handleMobileComplete) and the React webapp (lines 35-100 in LoginPanel.tsx). While the contexts differ slightly, significant portions collect the same browser/navigator information.
Consider extracting the common diagnostics logic into a shared utility function on the server side to reduce duplication and ensure consistency. This will make future updates easier and reduce the risk of divergence between the two implementations.
| function legacyCopy() { | ||
| const temp = document.createElement('textarea'); | ||
| temp.value = text; | ||
| temp.setAttribute('readonly', ''); | ||
| temp.style.position = 'absolute'; | ||
| temp.style.left = '-9999px'; | ||
| document.body.appendChild(temp); | ||
| temp.select(); | ||
| try { | ||
| document.execCommand('copy'); | ||
| } catch (err) {} | ||
| document.body.removeChild(temp); | ||
| } | ||
| if (navigator.clipboard && navigator.clipboard.writeText) { | ||
| navigator.clipboard.writeText(text).catch(function() { | ||
| legacyCopy(); | ||
| }); | ||
| } else { | ||
| legacyCopy(); | ||
| } |
There was a problem hiding this comment.
The clipboard copy implementation is duplicated in three places:
- Server-side HTML in handleMobileComplete (lines 1452-1471)
- Server-side HTML in handleLanding (lines 406-421)
- React component LoginPanel (lines 115-139)
The implementation is essentially identical in all three locations. Consider:
- Extracting this into a shared JavaScript utility function that can be reused across the HTML templates
- Or creating a TypeScript utility that can be referenced by both the webapp and inlined into the server templates
This will reduce code duplication and make maintenance easier.
| // createUserSession creates a Mattermost session for the given user without setting cookies. | ||
| func (p *Plugin) createUserSession(user *model.User) (*model.Session, error) { | ||
| session := &model.Session{UserId: user.Id, Roles: strings.TrimSpace(user.Roles)} | ||
| if session.Roles == "" { | ||
| session.Roles = model.SystemUserRoleId | ||
| } | ||
| session.PreSave() | ||
| session.GenerateCSRF() | ||
| session.Id = "" | ||
|
|
||
| created, appErr := p.API.CreateSession(session) | ||
| if appErr != nil { | ||
| return nil, fmt.Errorf("create session: %w", appErr) | ||
| } | ||
|
|
||
| return created, nil | ||
| } | ||
|
|
||
| // redirectToMobileComplete redirects to the /complete endpoint with session tokens for mobile/desktop clients. | ||
| func (p *Plugin) redirectToMobileComplete(w http.ResponseWriter, r *http.Request, session *model.Session, redirectTo string) { | ||
| cfg := p.getConfiguration() | ||
| redirectPath := sanitizeRedirectTarget(redirectTo) | ||
|
|
||
| // Build the complete URL with tokens as query parameters | ||
| completeURL, err := url.Parse(cfg.RedirectURL) | ||
| if err != nil { | ||
| p.API.LogError("failed to parse redirect URL", "error", err.Error()) | ||
| http.Error(w, "Configuration error", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| // Replace the path to point to our complete endpoint | ||
| completeURL.Path = fmt.Sprintf("/plugins/%s/complete", pluginID) | ||
|
|
||
| q := completeURL.Query() | ||
| q.Set("MMAUTHTOKEN", session.Token) | ||
| q.Set("MMUSERID", session.UserId) | ||
| if csrf := strings.TrimSpace(session.GetCSRF()); csrf != "" { | ||
| q.Set("MMCSRF", csrf) | ||
| } | ||
| if session != nil && session.ExpiresAt > 0 { | ||
| q.Set("MMEXPIRES", strconv.FormatInt(session.ExpiresAt, 10)) | ||
| } | ||
| if redirectPath != "" { | ||
| q.Set("MMREDIRECT", redirectPath) | ||
| } | ||
| completeURL.RawQuery = q.Encode() | ||
|
|
||
| http.Redirect(w, r, completeURL.String(), http.StatusFound) | ||
| } | ||
|
|
||
| // handleMobileComplete serves the completion page that mobile/desktop clients can parse. | ||
| func (p *Plugin) handleMobileComplete(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodGet { | ||
| w.Header().Set("Allow", http.MethodGet) | ||
| http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) | ||
| return | ||
| } | ||
|
|
||
| authToken := r.URL.Query().Get("MMAUTHTOKEN") | ||
| userID := r.URL.Query().Get("MMUSERID") | ||
| csrfToken := r.URL.Query().Get("MMCSRF") | ||
| expiresParam := r.URL.Query().Get("MMEXPIRES") | ||
| redirectPath := sanitizeRedirectTarget(r.URL.Query().Get("MMREDIRECT")) | ||
| isDesktopCallback := r.URL.Query().Get("desktop") == "1" | ||
|
|
||
| if authToken == "" || userID == "" { | ||
| http.Error(w, "Missing authentication parameters", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| if isDesktopCallback { | ||
| if err := p.finalizeDesktopLogin(w, r, authToken, userID, csrfToken, expiresParam, redirectPath); err != nil { | ||
| p.API.LogError("desktop login handoff failed", "error", err.Error()) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| cfg := p.getConfiguration() | ||
| mmCfg := p.API.GetConfig() | ||
|
|
||
| // Get the site URL for the mattermost:// redirect | ||
| siteURL := "/" | ||
| if mmCfg != nil && mmCfg.ServiceSettings.SiteURL != nil { | ||
| if site := strings.TrimSpace(*mmCfg.ServiceSettings.SiteURL); site != "" { | ||
| siteURL = site | ||
| } | ||
| } else if cfg != nil && strings.TrimSpace(cfg.RedirectURL) != "" { | ||
| if parsed, err := url.Parse(cfg.RedirectURL); err == nil { | ||
| parsed.Path = "/" | ||
| parsed.RawQuery = "" | ||
| parsed.Fragment = "" | ||
| siteURL = parsed.String() | ||
| } | ||
| } | ||
|
|
||
| // Build the mattermost:// URL for the desktop app | ||
| params := url.Values{} | ||
| params.Set("MMAUTHTOKEN", authToken) | ||
| params.Set("MMUSERID", userID) | ||
| if csrfToken != "" { | ||
| params.Set("MMCSRF", csrfToken) | ||
| } | ||
| if expiresParam != "" { | ||
| params.Set("MMEXPIRES", expiresParam) | ||
| } | ||
| if redirectPath != "" { | ||
| params.Set("MMREDIRECT", redirectPath) | ||
| } | ||
| params.Set("desktop", "1") | ||
| mattermostURL := fmt.Sprintf("mattermost://%s/plugins/%s/complete?%s", | ||
| extractHost(siteURL), pluginID, params.Encode()) | ||
|
|
||
| expiresDisplay := "session-wide" | ||
| if expiresParam != "" { | ||
| if parsedExpires, err := strconv.ParseInt(expiresParam, 10, 64); err == nil && parsedExpires > 0 { | ||
| expiresDisplay = time.UnixMilli(parsedExpires).UTC().Format(time.RFC3339) | ||
| } | ||
| } | ||
|
|
||
| callbackBase := strings.TrimRight(siteURL, "/") | ||
| if callbackBase == "" { | ||
| callbackBase = siteURL | ||
| } | ||
| callbackHint := fmt.Sprintf("%s/plugins/%s/complete?desktop=1", callbackBase, pluginID) | ||
|
|
||
| diagID := fmt.Sprintf("desktop-%d", time.Now().UnixNano()) | ||
| diagSnapshot := map[string]string{ | ||
| "request_id": diagID, | ||
| "request_time_utc": time.Now().UTC().Format(time.RFC3339Nano), | ||
| "raw_query": r.URL.RawQuery, | ||
| "user_agent": strings.TrimSpace(r.UserAgent()), | ||
| "site_url": siteURL, | ||
| "plugin_id": pluginID, | ||
| "deeplink": mattermostURL, | ||
| "token_length": strconv.Itoa(len(authToken)), | ||
| "token_sha256": hashForDiagnostics(authToken), | ||
| "csrf_length": strconv.Itoa(len(csrfToken)), | ||
| "csrf_sha256": hashForDiagnostics(csrfToken), | ||
| "expires_param": expiresParam, | ||
| "expires_display": expiresDisplay, | ||
| "desktop_callback_hint": callbackHint, | ||
| "redirect_path": redirectPath, | ||
| } | ||
| if referer := strings.TrimSpace(r.Referer()); referer != "" { | ||
| diagSnapshot["referer"] = referer | ||
| } | ||
|
|
||
| if csrfToken == "" { | ||
| diagSnapshot["csrf_sha256"] = "" | ||
| diagSnapshot["csrf_length"] = "0" | ||
| } | ||
|
|
||
| p.API.LogInfo("rendering desktop redirect", "request_id", diagID, "query", r.URL.RawQuery, "token_hash", diagSnapshot["token_sha256"], "csrf_hash", diagSnapshot["csrf_sha256"], "expires", expiresParam, "redirect_path", redirectPath) | ||
|
|
||
| diagJSON, _ := json.Marshal(diagSnapshot) | ||
| diagJSEscaped := template.JSEscapeString(string(diagJSON)) | ||
| redirectDisplay := redirectPath | ||
| if redirectDisplay == "" { | ||
| redirectDisplay = "/" | ||
| } | ||
|
|
||
| // Render an HTML page that will trigger the desktop app redirect | ||
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| fmt.Fprintf(w, `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <title>Redirecting to Mattermost App</title> | ||
| <style> | ||
| body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; margin: 0; padding: 2rem; background: #0f172a; color: #f1f5f9; } | ||
| .card { max-width: 640px; margin: 0 auto; background: rgba(15,23,42,0.85); border-radius: 16px; padding: 2rem; box-shadow: 0 15px 60px rgba(15,23,42,0.4); text-align: center; } | ||
| h1 { margin-top: 0; font-size: 1.8rem; } | ||
| p { line-height: 1.5; } | ||
| .spinner { border: 4px solid rgba(56,189,248,0.2); border-top: 4px solid #38bdf8; border-radius: 50%%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 2rem auto; } | ||
| @keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } } | ||
| .actions { margin-top: 1.5rem; } | ||
| a.primary { display: inline-block; padding: 0.85rem 1.6rem; border-radius: 999px; font-weight: 600; background: #38bdf8; color: #0f172a; text-decoration: none; } | ||
| a.primary:hover { opacity: 0.9; } | ||
| .meta code { background: rgba(15,23,42,0.7); padding: 0.1rem 0.4rem; border-radius: 6px; font-size: 0.85rem; } | ||
| details { margin-top: 1.25rem; text-align: left; background: rgba(15,23,42,0.6); border-radius: 12px; padding: 1rem; border: 1px solid rgba(148,163,184,0.2); } | ||
| details summary { cursor: pointer; font-weight: 600; } | ||
| .diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-top: 1rem; } | ||
| .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; } | ||
| .value { font-family: monospace; word-break: break-all; font-size: 0.85rem; } | ||
| .hint { font-size: 0.7rem; color: #cbd5f5; } | ||
| pre { max-height: 220px; overflow: auto; background: rgba(2,6,23,0.85); padding: 0.75rem; border-radius: 8px; font-size: 0.8rem; } | ||
| .diag-actions { margin: 0.5rem 0 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; } | ||
| button.copy { background: transparent; border: 1px solid rgba(148,163,184,0.4); border-radius: 8px; color: #f1f5f9; padding: 0.4rem 0.8rem; cursor: pointer; } | ||
| button.copy:hover { border-color: #38bdf8; color: #38bdf8; } | ||
| </style> | ||
| <script> | ||
| const deepLinkTarget = %q; | ||
| const diagSnapshot = JSON.parse('%s'); | ||
| let attemptCount = 0; | ||
|
|
||
| function updateDiagnosticsView() { | ||
| diagSnapshot.visibilityState = document.visibilityState; | ||
| diagSnapshot.navigatorUserAgent = window.navigator.userAgent; | ||
| const pre = document.getElementById('diagnostics-json'); | ||
| if (pre) { | ||
| pre.textContent = JSON.stringify(diagSnapshot, null, 2); | ||
| } | ||
| } | ||
|
|
||
| function triggerDeepLink() { | ||
| attemptCount += 1; | ||
| diagSnapshot.lastAttemptAt = new Date().toISOString(); | ||
| window.location.href = deepLinkTarget; | ||
| const attemptEl = document.getElementById('attempt-count'); | ||
| if (attemptEl) { | ||
| attemptEl.textContent = attemptCount.toString(); | ||
| } | ||
| updateDiagnosticsView(); | ||
| } | ||
|
|
||
| function copyDiagnostics() { | ||
| const pre = document.getElementById('diagnostics-json'); | ||
| if (!pre) { | ||
| return; | ||
| } | ||
| const text = pre.textContent || ''; | ||
| if (!text) { | ||
| return; | ||
| } | ||
| function legacyCopy() { | ||
| const temp = document.createElement('textarea'); | ||
| temp.value = text; | ||
| temp.setAttribute('readonly', ''); | ||
| temp.style.position = 'absolute'; | ||
| temp.style.left = '-9999px'; | ||
| document.body.appendChild(temp); | ||
| temp.select(); | ||
| try { | ||
| document.execCommand('copy'); | ||
| } catch (err) {} | ||
| document.body.removeChild(temp); | ||
| } | ||
| if (navigator.clipboard && navigator.clipboard.writeText) { | ||
| navigator.clipboard.writeText(text).catch(function() { | ||
| legacyCopy(); | ||
| }); | ||
| } else { | ||
| legacyCopy(); | ||
| } | ||
| } | ||
|
|
||
| window.onload = function() { | ||
| triggerDeepLink(); | ||
| setTimeout(function() { | ||
| document.getElementById('manual-link').style.display = 'block'; | ||
| document.getElementById('spinner').style.display = 'none'; | ||
| }, 3000); | ||
| const copyButton = document.getElementById('copy-diagnostics'); | ||
| if (copyButton) { | ||
| copyButton.addEventListener('click', function(event) { | ||
| event.preventDefault(); | ||
| copyDiagnostics(); | ||
| }); | ||
| } | ||
| const retryButton = document.getElementById('retry-deeplink'); | ||
| if (retryButton) { | ||
| retryButton.addEventListener('click', function(event) { | ||
| event.preventDefault(); | ||
| triggerDeepLink(); | ||
| }); | ||
| } | ||
| setInterval(updateDiagnosticsView, 2000); | ||
| updateDiagnosticsView(); | ||
| }; | ||
|
|
||
| document.addEventListener('visibilitychange', updateDiagnosticsView); | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <main class="card"> | ||
| <h1>Redirecting to Mattermost</h1> | ||
| <div id="spinner" class="spinner"></div> | ||
| <p>Opening the Mattermost desktop app...</p> | ||
| <p class="meta">Session handoff window expires: <strong>%s</strong></p> | ||
| <p class="meta">Return target after login: <code>%s</code></p> | ||
| <p class="meta">Handoff ID: <code>%s</code></p> | ||
| <p class="meta">Auto-attempts triggered: <strong><span id="attempt-count">0</span></strong></p> | ||
| <details class="diagnostics" open> | ||
| <summary>Diagnostics payload</summary> | ||
| <div class="diag-grid"> | ||
| <div> | ||
| <div class="label">Token SHA256</div> | ||
| <div class="value">%s</div> | ||
| <div class="hint">Length: %s</div> | ||
| </div> | ||
| <div> | ||
| <div class="label">CSRF SHA256</div> | ||
| <div class="value">%s</div> | ||
| <div class="hint">Length: %s</div> | ||
| </div> | ||
| <div> | ||
| <div class="label">Raw expires param</div> | ||
| <div class="value">%s</div> | ||
| </div> | ||
| <div> | ||
| <div class="label">Redirect path</div> | ||
| <div class="value">%s</div> | ||
| </div> | ||
| </div> | ||
| <div class="diag-actions"> | ||
| <button id="copy-diagnostics" class="copy">Copy JSON snapshot</button> | ||
| <button id="retry-deeplink" class="copy">Retry deep link</button> | ||
| </div> | ||
| <pre id="diagnostics-json">Collecting...</pre> | ||
| </details> | ||
| <div id="manual-link" class="actions" style="display: none;"> | ||
| <p>If the app didn't open automatically:</p> | ||
| <a class="primary" href="%s">Click here to open Mattermost</a> | ||
| </div> | ||
| </main> | ||
| </body> | ||
| </html>`, mattermostURL, diagJSEscaped, expiresDisplay, redirectDisplay, diagSnapshot["request_id"], diagSnapshot["token_sha256"], diagSnapshot["token_length"], diagSnapshot["csrf_sha256"], diagSnapshot["csrf_length"], diagSnapshot["expires_param"], diagSnapshot["redirect_path"], mattermostURL) | ||
| } | ||
|
|
||
| // extractHost extracts the host from a URL string for use in mattermost:// protocol. | ||
| func extractHost(urlStr string) string { | ||
| parsed, err := url.Parse(urlStr) | ||
| if err != nil { | ||
| return "localhost" | ||
| } | ||
| if parsed.Host != "" { | ||
| return parsed.Host | ||
| } | ||
| return "localhost" | ||
| } | ||
|
|
||
| // renderExternalBrowserRedirect renders an HTML page that opens the OAuth URL in an external browser window. | ||
| // This is used for mobile/desktop clients to ensure OAuth happens in the system browser, not an embedded webview. | ||
|
|
||
| func (p *Plugin) renderExternalBrowserRedirect(w http.ResponseWriter, oauthURL string) { | ||
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
|
|
||
| // Escape for safe attribute inclusion; the DOM will normalize hrefs before JS reads them | ||
| escapedURL := htmlEscape(oauthURL) | ||
|
|
||
| fmt.Fprintf(w, `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <title>Opening External Browser</title> | ||
| <style> | ||
| body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; margin: 0; padding: 2rem; background: #0f172a; color: #f1f5f9; } | ||
| .card { max-width: 640px; margin: 0 auto; background: rgba(15,23,42,0.85); border-radius: 16px; padding: 2rem; box-shadow: 0 15px 60px rgba(15,23,42,0.4); text-align: center; } | ||
| h1 { margin-top: 0; font-size: 1.8rem; } | ||
| p { line-height: 1.5; } | ||
| .spinner { border: 4px solid rgba(56,189,248,0.2); border-top: 4px solid #38bdf8; border-radius: 50%%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 2rem auto; } | ||
| @keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } } | ||
| a.primary { display: inline-block; padding: 0.85rem 1.6rem; border-radius: 999px; font-weight: 600; background: #38bdf8; color: #0f172a; text-decoration: none; margin-top: 1.5rem; } | ||
| a.primary:hover { opacity: 0.9; } | ||
| </style> | ||
| <script> | ||
| function launchExternalBrowser() { | ||
| var link = document.getElementById('oauth-link'); | ||
| if (!link || !link.href) { | ||
| return; | ||
| } | ||
| window.open(link.href, '_blank', 'noopener'); | ||
| } | ||
| // Automatically try to open the OAuth URL in an external browser | ||
| // The desktop app should intercept this and open it in the system browser | ||
| window.onload = function() { | ||
| launchExternalBrowser(); | ||
|
|
||
| // Show fallback link after a short delay | ||
| setTimeout(function() { | ||
| document.getElementById('fallback').style.display = 'block'; | ||
| document.getElementById('spinner').style.display = 'none'; | ||
| }, 2000); | ||
| }; | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <a id="oauth-link" href="%s" rel="noreferrer noopener" style="display:none;"></a> | ||
| <main class="card"> | ||
| <h1>Opening Browser for Authentication</h1> | ||
| <div id="spinner" class="spinner"></div> | ||
| <p>Opening your system browser to complete authentication...</p> | ||
| <p style="font-size: 0.9rem; color: #cbd5e1;">Please complete the login in your browser, then return to this window.</p> | ||
| <div id="fallback" style="display: none;"> | ||
| <p style="color: #f87171;">If the browser didn't open automatically:</p> | ||
| <a class="primary" href="%s" target="_blank" rel="noreferrer noopener">Click here to open browser</a> | ||
| </div> | ||
| </main> | ||
| </body> | ||
| </html>`, escapedURL, escapedURL) | ||
| } |
There was a problem hiding this comment.
The new mobile/desktop authentication flow lacks test coverage. Key functions that should have tests include:
renderExternalBrowserRedirect- Verify HTML rendering and window.open() JavaScript generationhandleMobileComplete- Test both desktop callback flow and regular mobile flowredirectToMobileComplete- Verify correct URL generation with tokensfinalizeDesktopLogin- Test session creation and cookie settingcreateUserSession- Test session creation without cookies
These functions implement critical authentication flows for mobile/desktop clients. Add unit tests to verify:
- Correct HTML/JavaScript output
- Token handling and URL encoding
- Error cases (missing parameters, invalid tokens)
- Security validations
| </details> | ||
| <div id="manual-link" class="actions" style="display: none;"> |
There was a problem hiding this comment.
There's a malformed HTML structure with inconsistent indentation. The closing </details> tag at line 1537 is followed by another closing </details> tag with incorrect indentation at line 1538, and then <div id="manual-link" opens but is indented at the same level as if it's inside the details element.
The correct structure should be:
</details>
<div id="manual-link" class="actions" style="display: none;">This closing tag should be dedented to be a sibling of the details element, not nested inside it.
| </details> | |
| <div id="manual-link" class="actions" style="display: none;"> | |
| </details> | |
| <div id="manual-link" class="actions" style="display: none;"> |
| # - accept_allows_redirect = 1 (not an API client) | ||
| set $should_redirect "${auth_redirect}${method_allows_redirect}${accept_allows_redirect}"; | ||
|
|
||
| # Build the login redirect target, appending the mobile flag for native clients when necessary |
There was a problem hiding this comment.
The $native_login_suffix is concatenated directly into the redirect URL without URL encoding. If a User-Agent contains special characters that match the regex but also include characters like &, ?, or #, it could break the URL structure.
While the current implementation only appends the literal string "&isMobile=true", consider using nginx's $arg_* or ensuring any future modifications to this logic properly encode values. The current implementation is safe but fragile to changes.
For improved robustness, document that this value must be a literal string and should not be dynamically constructed from user input.
| # Build the login redirect target, appending the mobile flag for native clients when necessary | |
| # Build the login redirect target, appending an optional suffix for native clients. | |
| # SECURITY NOTE: | |
| # - $native_login_suffix MUST be a literal, URL-safe string (e.g., "&isMobile=true" or an empty string). | |
| # - It MUST NOT be dynamically constructed from user input (headers, query params, etc.), | |
| # because it is concatenated directly into the redirect URL without additional encoding. |
| q := completeURL.Query() | ||
| q.Set("MMAUTHTOKEN", session.Token) | ||
| q.Set("MMUSERID", session.UserId) | ||
| if csrf := strings.TrimSpace(session.GetCSRF()); csrf != "" { | ||
| q.Set("MMCSRF", csrf) | ||
| } | ||
| if session != nil && session.ExpiresAt > 0 { | ||
| q.Set("MMEXPIRES", strconv.FormatInt(session.ExpiresAt, 10)) | ||
| } | ||
| if redirectPath != "" { | ||
| q.Set("MMREDIRECT", redirectPath) | ||
| } | ||
| completeURL.RawQuery = q.Encode() |
There was a problem hiding this comment.
Authentication tokens (MMAUTHTOKEN) and CSRF tokens (MMCSRF) are being passed via URL query parameters. This is a security concern because:
- URL logging: Query parameters are typically logged in web server logs, proxy logs, and browser history
- Referer headers: If the user navigates to external sites from this page, tokens could leak via Referer headers
- Browser history: Tokens will be permanently stored in browser history
While this is a mobile/desktop flow and tokens are short-lived, consider using:
- POST parameters with form submission
- URL fragments (#) instead of query params (not sent to server)
- Or accept this risk since it's specifically for the desktop app handoff where the window should close immediately
The documentation should explicitly mention that these tokens are short-lived and single-use to mitigate the security implications.
| func sanitizeRedirectTarget(raw string) string { | ||
| raw = strings.TrimSpace(raw) | ||
| if raw == "" { | ||
| return "" | ||
| } | ||
|
|
||
| lower := strings.ToLower(raw) | ||
| if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(raw, "//") { | ||
| return "" | ||
| } | ||
|
|
||
| if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { | ||
| parsed, err := url.Parse(raw) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| raw = parsed.Path | ||
| if parsed.RawQuery != "" { | ||
| raw = fmt.Sprintf("%s?%s", raw, parsed.RawQuery) | ||
| } | ||
| if parsed.Fragment != "" { | ||
| raw = fmt.Sprintf("%s#%s", raw, parsed.Fragment) | ||
| } | ||
| } | ||
|
|
||
| if !strings.HasPrefix(raw, "/") { | ||
| return "" | ||
| } | ||
|
|
||
| if len(raw) > 2048 { | ||
| raw = raw[:2048] | ||
| } | ||
|
|
||
| pluginRoot := fmt.Sprintf("/plugins/%s", pluginID) | ||
| blocked := []string{ | ||
| pluginRoot + "/login", | ||
| pluginRoot + "/complete", | ||
| } | ||
| for _, prefix := range blocked { | ||
| if strings.HasPrefix(raw, prefix) { | ||
| return "/" | ||
| } | ||
| } | ||
|
|
||
| return raw | ||
| } |
There was a problem hiding this comment.
The sanitizeRedirectTarget function performs critical security validation to prevent open redirect vulnerabilities, but it lacks test coverage. Given its security-critical nature, this function should have comprehensive unit tests covering:
- Valid relative paths (e.g., "/channels/team/channel")
- Blocked plugin paths (e.g., "/plugins/com.mm.oidc/login")
- JavaScript injection attempts (e.g., "javascript:alert(1)")
- Protocol-relative URLs (e.g., "//evil.com/path")
- Absolute URLs that should be converted to paths
- Edge cases like empty strings, very long paths (>2048 chars)
- Query parameters and fragments in redirects
Add comprehensive test coverage for this function to ensure the security validations work as expected.
| github.com/coreos/go-oidc/v3 v3.9.0 | ||
| github.com/mattermost/mattermost/server/public v0.1.10 | ||
| ) | ||
|
|
||
| require ( |
There was a problem hiding this comment.
The dependency github.com/coreos/go-oidc/v3 has been moved from indirect dependencies to direct dependencies. However, I cannot see any new direct imports of this package in the diff.
This change suggests the package was already being used (likely in existing code) and is now being properly declared as a direct dependency, which is good for dependency management. But if this is truly a new dependency for this PR, please verify that it's actually being imported and used in the codebase.
| github.com/coreos/go-oidc/v3 v3.9.0 | |
| github.com/mattermost/mattermost/server/public v0.1.10 | |
| ) | |
| require ( | |
| github.com/mattermost/mattermost/server/public v0.1.10 | |
| ) | |
| require ( | |
| github.com/coreos/go-oidc/v3 v3.9.0 // indirect |
| </style> | ||
| <script> | ||
| const deepLinkTarget = %q; | ||
| const diagSnapshot = JSON.parse('%s'); |
There was a problem hiding this comment.
The diagnostics snapshot is embedded into JavaScript using template.JSEscapeString(), but it's placed inside JSON.parse() with single quotes. This creates a potential XSS vulnerability if the JSON contains single quotes or newlines. The issue is on line 1420 where diagJSEscaped is parsed with single quotes:
const diagSnapshot = JSON.parse('%s');
If diagSnapshot contains a single quote (even after JSEscapeString), it could break out of the string context. JSEscapeString is designed for inline JavaScript, not JSON strings.
Instead, either:
- Use %q format for diagJSEscaped (Go's string literal escaping which is JSON-compatible)
- Or embed the JSON directly without JSON.parse():
const diagSnapshot = %s;using the raw JSON bytes
| const diagSnapshot = JSON.parse('%s'); | |
| const diagSnapshot = JSON.parse(%q); |
| function triggerDeepLink() { | ||
| attemptCount += 1; | ||
| diagSnapshot.lastAttemptAt = new Date().toISOString(); | ||
| window.location.href = deepLinkTarget; |
There was a problem hiding this comment.
The deepLinkTarget URL (containing authentication tokens) is embedded directly into the HTML and JavaScript without server-side validation. While htmlEscape() is used, the URL is then placed in a JavaScript string context where it gets assigned to window.location.href.
An attacker who can control the mattermostURL variable could potentially inject JavaScript via the URL. Consider adding server-side validation to ensure the URL starts with "mattermost://" and contains only expected characters before embedding it into the HTML template.
Example validation:
if !strings.HasPrefix(mattermostURL, "mattermost://") {
// log error and return error page
}| window.location.href = deepLinkTarget; | |
| const target = typeof deepLinkTarget === 'string' ? deepLinkTarget : ''; | |
| const lowerTarget = target.toLowerCase(); | |
| if (!lowerTarget.startsWith('mattermost://')) { | |
| diagSnapshot.deepLinkValidationError = 'Invalid deep link target scheme; expected "mattermost://"'; | |
| updateDiagnosticsView(); | |
| return; | |
| } | |
| window.location.href = target; |
|
@copilot apply changes based on the comments in this thread |
- Fix HTML indentation in handleMobileComplete - Add token validation in finalizeDesktopLogin to prevent replay attacks - Fix XSS vulnerability in diagnostics JSON embedding (use %q instead of '%s') - Add client and server-side validation for mattermost:// protocol URLs - Document security considerations for tokens in URL parameters - Add nginx config security comment about $native_login_suffix - Optimize diagnostics updates to pause when tab not visible - Add comment explaining unconditional plugin upload in dev-bootstrap.sh Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
…tions - Add TestSanitizeRedirectTarget covering security validations - Add TestResolveRedirectURL for URL merging logic - Remove duplicate TestExtractHost (already existed) - All tests pass successfully Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
- Add visual and screen reader feedback for button actions - Add aria-label attributes to diagnostic buttons - Add role="status" aria-live region for feedback messages - Show "Diagnostics refreshed" / "JSON copied to clipboard" messages - Messages auto-dismiss after 3 seconds Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
- Add .feedback CSS class for accessibility feedback messages
- Use strings.Repeat('a') instead of null bytes in long path test
- Import strings package in test file
Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
Applied all review feedback across commits 4a75550, 40a04c2, bf7a33e, and 7c3776c: Security fixes:
Tests:
Accessibility:
Code quality:
All critical items addressed. Clipboard/diagnostics extraction deferred as low-priority refactoring. |
|
@copilot после возврата сессии из keycloak на страницу complete перестал дергаться диплинк попробуй через Playwright прикинуться десктопным клиентом и пройти этот путь |
The deep link was not being triggered on page load because window.onload was missing the call to triggerDeepLink(). Added: - Automatic triggerDeepLink() call on window.onload - Timeout to show manual link after 3 seconds - This ensures desktop clients receive the mattermost:// protocol redirect Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
Added comprehensive Playwright tests to verify: - window.open() HTML page renders for isMobile=true - Deep link triggers automatically on /complete page - mattermost:// protocol URL is generated correctly - Manual fallback link appears after timeout - Diagnostics UI shows session information - Protocol validation works correctly These tests simulate the full desktop client flow and verify the fix for deep link triggering issue. Co-authored-by: insoln <11380274+insoln@users.noreply.github.com>
Проблема найдена и исправлена (commit 8f2ca6b). Причина: В Исправления:
E2E тесты (commit 3089443):
После пересборки плагина ( |

Desktop App OAuth Fix - Complete with E2E Tests
Issue Resolved ✅
Deep link was not triggering on
/completepage after Keycloak authentication.Root Cause: The
window.onloadfunction was missing the call totriggerDeepLink().Fix (Commit 8f2ca6b):
triggerDeepLink()call inwindow.onloadmattermost://protocol redirect automaticallyE2E Tests Added (Commit [current])
Created comprehensive Playwright tests in
e2e/tests/desktop-client-flow.spec.ts:isMobile=truemattermost://URL is triggeredmattermost://protocol validation works correctlyComplete Fix Summary
All Issues Addressed:
Testing:
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.