Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Securely connect Mattermost Server v9.x+ to modern OpenID Connect providers (Key
## What you get

- ✅ Full OIDC login flow with automatic user provisioning and optional admin promotion via client roles.
- ✅ Desktop app support (Windows, macOS, Linux) with token-based authentication flow.
- ✅ Ready-made Docker stack (Mattermost + Keycloak + Postgres + proxy) for demos, QA, and CI.
- ✅ Proxy recipe that rewrites `/login` to the plugin route without breaking API clients.
- ✅ Playwright regression suite that mirrors the documented installation steps.
Expand Down
31 changes: 31 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This document captures the initial architecture for the Mattermost OIDC plugin.
### Server (`server/`)

- **Auth Handlers** – `/login` generates Authorization Code + PKCE redirects (state, nonce, code verifier, KV-backed session storage) while `/callback` exchanges the authorization code for tokens, verifies the `id_token` against the provider's JWKS, and provisions/updates Mattermost users (persisting OIDC subject → user mappings in the plugin KV store). `/logout` revokes the current Mattermost session, deletes the encrypted token bundle, clears cookies, and redirects the browser to the provider's `end_session_endpoint` when exposed.
- **Desktop Token Flow** – `/login/desktop` and `/login/desktop_token` endpoints support Mattermost desktop application authentication. When `desktop_token` query parameter is present, the callback generates server tokens and redirects to `mattermost://callback` URL scheme instead of setting session cookies. Desktop app exchanges these tokens for a session via the `/login/desktop_token` API endpoint.
- **Claim Mapper** – maps ID token / userinfo claims to Mattermost user fields, supports custom transformations and enforcement (e.g., domain allowlists, role mapping).
- **Provisioning Service** – creates or links Mattermost accounts, handles profile sync, and enforces plugin-specific policies (auto-provision vs. invite-only).
- **Config Manager** – validates admin-provided settings, caches OIDC discovery metadata, rotates secrets, and integrates with Mattermost's configuration store.
Expand Down Expand Up @@ -62,6 +63,36 @@ Validation logic ensures:
- **Logging** – structured JSON logs with correlation IDs for each login attempt.
- **Dependency hygiene** – Dependabot and `npm audit`/`govulncheck` integrated into CI.

## Desktop Application Support

The plugin supports authentication for Mattermost desktop applications (Windows, macOS, Linux) using a token-based flow compatible with the desktop app protocol:

### Desktop Authentication Flow

1. **Desktop-initiated login**: Desktop app opens `/plugins/com.mm.oidc/login?desktop_token=<client_token>` in the system browser
2. **OAuth flow**: User completes OIDC authentication with the provider (same as web flow)
3. **Token generation**: On callback, plugin detects `desktop_token` in session state and:
- Generates a server token stored in KV store (3-minute TTL)
- Redirects to `/plugins/com.mm.oidc/login/desktop` with both tokens
4. **Desktop redirect**: Browser auto-redirects to `mattermost://callback?client_token=...&server_token=...`
5. **Session creation**: Desktop app calls `/plugins/com.mm.oidc/login/desktop_token` API with server token to create session

### Key Implementation Details

- **Desktop token storage**: Server tokens stored in plugin KV store with 3-minute expiration
- **URL scheme handling**: Uses `mattermost://` protocol for production, `mattermost-dev://` for development builds
- **Security**: Tokens are single-use and automatically deleted after validation or expiration
- **Compatibility**: Tested with Mattermost Desktop v5.13+ and Server v10.11+

### Parameter Support

The `/login` endpoint accepts the following query parameters for desktop/mobile flows:
- `desktop_token` – Token provided by desktop app for session creation
- `redirect_to` – Optional redirect URL after successful authentication
- `mobile` – Boolean flag indicating mobile app flow (not yet fully implemented)

This implementation mirrors the desktop authentication flow from [Mattermost Server v10.11](https://github.com/mattermost/mattermost/tree/release-10.11), ensuring compatibility with current desktop app releases.

## Observability

- Server exports Prometheus metrics (e.g., `oidc_login_attempt_total`, `oidc_login_duration_seconds_bucket`).
Expand Down
18 changes: 18 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,27 @@ Each section contains prerequisites, ordered steps, verification tips, and expli

Automated regression scripts live in [docs/DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md) if you need repeatable validation for CI.

### Desktop App Authentication

The plugin fully supports Mattermost desktop applications (Windows, macOS, Linux) using a token-based authentication flow:

1. **Configuration**: The desktop app uses your server's configured authentication method automatically.
2. **Login flow**: When logging in from the desktop app:
- The app opens your default browser to the OIDC plugin login page
- You complete authentication with your Identity Provider
- The browser redirects back to the desktop app using the `mattermost://` URL scheme
- The desktop app exchanges tokens to create a session
3. **Requirements**:
- Mattermost Desktop v5.13 or newer
- Mattermost Server v9.0 or newer with this plugin installed
- Same IdP configuration as web login (no separate setup needed)

If you experience issues with desktop app authentication (e.g., buttons not responding after redirect), ensure you're using a compatible desktop app version and the plugin is properly configured.

### Known limitations in vanilla Mattermost

- The plugin **cannot override** the stock `/login` and `/logout` routes; only `/plugins/com.mm.oidc/` and `/plugins/com.mm.oidc/login` launch the flow.
- **Desktop applications**: Mattermost desktop app (v5.13+) is fully supported via the desktop token flow. Desktop app will automatically use the plugin for authentication when configured.
- Mobile apps and legacy password clients must continue using the built-in authentication methods until you add a proxy rule or custom UI entry point.
- Server metrics/logging already redact secrets, but Mattermost still displays raw IdP URLs inside the System Console; secure that interface appropriately.

Expand Down
224 changes: 224 additions & 0 deletions server/desktop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package main

import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"

"github.com/mattermost/mattermost/server/public/model"
)

const (
desktopTokenTTL = 3 * time.Minute
desktopTokenBytes = 48
desktopTokenKeyPrefix = "desktop_token:"
desktopSessionKeyPrefix = "desktop_session:"
)

// desktopTokenRecord stores the mapping from desktop token to user ID
type desktopTokenRecord struct {
UserID string `json:"user_id"`
CreatedAt int64 `json:"created_at"`
}

// generateDesktopToken creates a random token for desktop app authentication
func generateDesktopToken() (string, error) {
b := make([]byte, desktopTokenBytes)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate desktop token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

// saveDesktopToken stores the desktop token mapping
func (p *Plugin) saveDesktopToken(token string, userID string) error {
record := &desktopTokenRecord{
UserID: userID,
CreatedAt: time.Now().Unix(),
}

payload, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal desktop token record: %w", err)
}

ttlSeconds := int64(desktopTokenTTL.Seconds())
if appErr := p.API.KVSetWithExpiry(desktopTokenKey(token), payload, ttlSeconds); appErr != nil {
return fmt.Errorf("persist desktop token: %w", appErr)
}

return nil
}

// consumeDesktopToken retrieves and deletes the desktop token mapping
func (p *Plugin) consumeDesktopToken(token string) (string, error) {
data, appErr := p.API.KVGet(desktopTokenKey(token))
if appErr != nil {
return "", fmt.Errorf("load desktop token: %w", appErr)
}
if data == nil {
return "", fmt.Errorf("desktop token not found or expired")
}

// Delete the token immediately after reading
tokenPrefix := token
if len(tokenPrefix) > 8 {
tokenPrefix = token[:8]
}
if err := p.API.KVDelete(desktopTokenKey(token)); err != nil {
p.API.LogWarn("failed to delete desktop token", "token_prefix", tokenPrefix, "error", err.Error())
}

var record desktopTokenRecord
if err := json.Unmarshal(data, &record); err != nil {
return "", fmt.Errorf("decode desktop token record: %w", err)
}

// Check if token has expired
if time.Since(time.Unix(record.CreatedAt, 0)) > desktopTokenTTL {
return "", fmt.Errorf("desktop token expired")
}

return record.UserID, nil
}

func desktopTokenKey(token string) string {
return desktopTokenKeyPrefix + token
}

// handleLoginDesktop renders the desktop redirect page with tokens
func (p *Plugin) handleLoginDesktop(w http.ResponseWriter, r *http.Request) {
clientToken := r.URL.Query().Get("client_token")
serverToken := r.URL.Query().Get("server_token")
redirectTo := r.URL.Query().Get("redirect_to")
isDesktopDev := r.URL.Query().Get("isDesktopDev") == "true"

if clientToken == "" || serverToken == "" {
p.writeFriendlyError(w, 400, "Invalid request", "Missing required tokens for desktop login.")
return
}

// Build the desktop URL scheme redirect with properly escaped parameters
desktopURL := "mattermost://callback"
if isDesktopDev {
desktopURL = "mattermost-dev://callback"
}

queryParams := url.Values{}
queryParams.Set("client_token", clientToken)
queryParams.Set("server_token", serverToken)
if redirectTo != "" {
queryParams.Set("redirect_to", redirectTo)
}

fullRedirectURL := desktopURL + "?" + queryParams.Encode()

// Render HTML page that triggers desktop app
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Use html escaping to safely inject the URL into HTML
safeRedirectURL := template.HTMLEscapeString(fullRedirectURL)
fmt.Fprintf(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Launching Mattermost Desktop</title>
<meta http-equiv="refresh" content="0; url=%s" />
<style>
body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; margin: 0; padding: 2rem; background: #0f172a; color: #f1f5f9; text-align: center; }
.card { max-width: 560px; margin: 2rem auto; background: rgba(15,23,42,0.85); border-radius: 16px; padding: 2rem; }
h1 { margin-top: 0; font-size: 1.5rem; }
p { line-height: 1.5; margin: 1rem 0; }
.spinner { display: inline-block; width: 40px; height: 40px; margin: 1.5rem 0; border: 4px solid rgba(56,189,248,0.3); border-top-color: #38bdf8; border-radius: 50%%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
a { color: #38bdf8; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main class="card">
<h1>Launching Mattermost Desktop</h1>
<div class="spinner"></div>
<p>Redirecting to your desktop app...</p>
<p>If the app doesn't open automatically, <a href="%s">click here</a>.</p>
</main>
</body>
</html>`, safeRedirectURL, safeRedirectURL)
}

// handleLoginDesktopToken handles the API endpoint for desktop token login
func (p *Plugin) handleLoginDesktopToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}

var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}

token := payload["token"]
deviceID := payload["device_id"]

if token == "" {
http.Error(w, "Missing token", http.StatusBadRequest)
return
}

// Retrieve and consume the desktop token
userID, err := p.consumeDesktopToken(token)
if err != nil {
p.API.LogError("failed to validate desktop token", "error", err.Error())
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}

// Get the user
user, appErr := p.API.GetUser(userID)
if appErr != nil {
p.API.LogError("failed to get user for desktop token", "user_id", userID, "error", appErr.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}

// Create a session for the user
session := &model.Session{
UserId: user.Id,
Roles: strings.TrimSpace(user.Roles),
DeviceId: deviceID,
IsOAuth: true,
}
if session.Roles == "" {
session.Roles = model.SystemUserRoleId
}
session.PreSave()
session.GenerateCSRF()
// Clear the session ID so that CreateSession generates a new one.
// This is necessary because we're creating a new session, not updating an existing one.
session.Id = ""

createdSession, appErr := p.API.CreateSession(session)
if appErr != nil {
p.API.LogError("failed to create session for desktop token", "user_id", userID, "error", appErr.Error())
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}

// Return the user data with session token
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Token", createdSession.Token)

if err := json.NewEncoder(w).Encode(user); err != nil {
p.API.LogError("failed to encode user response", "error", err.Error())
}

p.API.LogDebug("desktop token login successful", "user_id", userID)
}
6 changes: 4 additions & 2 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ go 1.22.0

toolchain go1.24.2

require github.com/mattermost/mattermost/server/public v0.1.10
require (
github.com/coreos/go-oidc/v3 v3.9.0
github.com/mattermost/mattermost/server/public v0.1.10
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/coreos/go-oidc/v3 v3.9.0 // indirect
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
Expand Down
3 changes: 3 additions & 0 deletions server/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type authSession struct {
Nonce string `json:"nonce"`
CodeVerifier string `json:"code_verifier"`
CreatedAt int64 `json:"created_at"`
DesktopToken string `json:"desktop_token,omitempty"`
RedirectTo string `json:"redirect_to,omitempty"`
IsMobile bool `json:"is_mobile,omitempty"`
}

func (s *authSession) isExpired(now time.Time) bool {
Expand Down
Loading