diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8023fbb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/mods.htpasswd
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..db39e61
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Luxeria
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..77f96c8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,132 @@
+# jitsi-token-service
+
+This project provides an implementation of a token provider for the
+[Jitsi JWT authentication mechanism](https://github.com/jitsi/lib-jitsi-meet/blob/f5f1c314c9de875357b41784254aa73768d2bfdc/doc/tokens.md)
+for _self-hosted_ Jitsi Meet installations.
+In particular, it also is able to generate moderator tokens for the
+[`token_affiliation`](https://github.com/jitsi-contrib/prosody-plugins/blob/127ef7e89d6d36e1a1c95897c857f86a7f485a4a/token_affiliation/README.md)
+plugin.
+
+This effectively allows you to provide an external login system for authenticating
+Jitsi moderators (without the need)
+
+## Configuration
+
+### Environment
+
+This section describes the environment variables which are used to configure
+the token service:
+
+- `COOKIE_NAME` _(required)_: Name of the moderator cookie (example: `jitsi_mod_jwt`)
+- `JWT_VALIDITY` _(default: `1h`)_: Duration for which the generated token
+ should be valid for (example: `1h30m`, `300s`)
+- `JWT_SECRET` _(required)_: Secret JWT signing string, this value needs
+ to match the one configured in Jitsi meet (example: `my_jitsi_app_secret`)
+- `JWT_ISSUER` _(required)_: JWT issuer, this value needs
+ to match the one configured in Jitsi meet (example: `my_jitsi_app_id`)
+- `JWT_AUDIENCE` _(default: `jitsi`)_: JWT audience, this value needs
+ to match the one configured in Jitsi
+- `JWT_LEEWAY` _(default: `1m`)_: Maximum clock drift allowed for JWT validation
+- `JITSI_URL` _(required)_: Jitsi Meet base URL (without the room identifier)
+ (example: `https://meet.jitsi`)
+- `HTML_DOCROOT` _(default: `./html`)_: Directory containing the HTML login
+ form template
+- `HTML_TITLE` _(default: `Jitsi Moderator Login`)_: HTML title displayed to
+ the user on the login form
+- `MODS_FILE` _(default: `./mods.htpasswd`)_: Path to the htaccess file containing
+ the list of all authorized moderators with their password hash
+- `HTTP_ADDR` _(default: `:8080`)_: Address on which the HTTP server will listen on
+
+### Moderators File
+
+The `MODS_FILE` environment variable must point to a text file in the `htaccess`
+format containing all authorized moderators with their accompanying password
+hashes. Use one line per username and pasword pair. Empty lines or comment lines
+(starting with `#`) are allowed.
+
+The password hash needs to be a [bcrypt](https://en.wikipedia.org/wiki/Bcrypt)
+hash (cost 11 or higher is recommended).
+
+```apache
+# example mods.htaccess file:
+bob:$2y$12$EAZVfUzqmzqloNbfRAqY.e295IjhfHI/Ma2Tezqk8DryRZnkOT792
+alice:$2y$12$ianjnXNNHVQwiy3R15NsiO.lC04uEG7SEzP6qv/Rs1NZmNyPB6Muu
+```
+
+> [!NOTE]
+> The moderator file username is only used for authentication and is not
+> forwarded to Jitsi Meet in any form.
+> Moderators may use a different username as their "Display Name" when joining
+> a Jitsi Meet room.
+
+### Jitsi
+
+For details on how to configure your self-hosted instance of Jitsi,
+please refer to the Jitsi documentation.
+The following example illustrates what values may be relevant when using
+[jitsi/docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet):
+
+```shell
+# Enable JWT authentication
+ENABLE_AUTH=1
+AUTH_TYPE=jwt
+
+# JWT identifier and secret (shared between Jitsi and the token service)
+JWT_APP_ID=my_jitsi_app_id
+JWT_APP_SECRET=my_jitsi_app_secret # important: change this!
+
+# URL to this token service
+TOKEN_AUTH_URL=https://auth.jitsi:8080/autologin?room={room}
+
+# Enable the token_affiliation Prosody plugin
+XMPP_MUC_MODULES=token_affiliation
+```
+
+## Login Flow
+
+```mermaid
+sequenceDiagram
+ participant Browser as User Agent
+ participant Auth as Jitsi Token Service
(auth.jitsi)
+ participant Jitsi as Jitsi Meet
(meet.jitsi)
+
+ opt Moderator Login
+ activate Browser
+
+ Browser->>+Auth: GET https://auth.jitsi/login
+ Auth-->>-Browser: Login Page
+
+ Note over Browser: User provides
username and password
+
+ Browser->>+Auth: POST https://auth.jitsi/login
+ Note over Auth: Verifies username and password
+ Note over Auth: Generates and signs JWT
(with `moderator:true` claim)
+ Auth-->>-Browser: Store JWT in cookie
+
+ deactivate Browser
+ end
+
+ activate Browser
+ Browser->>+Jitsi: https://meet.jitsi/
+ Note over Jitsi: User is not authenticated.
Deny user to join room
+ Jitsi-->>-Browser: Redirect to Jitsi Token Service
+
+ Browser->>+Auth: GET https://auth.jitsi/autologin?room=
+ alt Cookie provided
+ Note over Auth: Verifies cookie and JWT.
Reuses JWT if valid
+ else Cookie missing
or invalid
+ Note over Auth: Generates and signs new JWT
(without moderator claim)
+ end
+ Auth-->>-Browser: Redirect to Jitsi Meet (with JWT)
+
+ Browser->>+Jitsi: https://meet.jitsi/?jwt=
+ Note over Jitsi: Verifies JWT signature
+ alt `moderator:true`
+ Note over Jitsi: Allow user to join and
grant moderator rights
+ else `moderator:false`
+ Note over Jitsi: Allow user to join
+ end
+ Jitsi-->>-Browser: Join room
+
+ deactivate Browser
+```
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..546bbdd
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,9 @@
+module github.com/luxeria/jitsi-token-service
+
+go 1.22.2
+
+require (
+ github.com/caarlos0/env/v11 v11.0.0
+ github.com/golang-jwt/jwt/v5 v5.2.1
+ golang.org/x/crypto v0.22.0
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..856c128
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/caarlos0/env/v11 v11.0.0 h1:ZIlkOjuL3xoZS0kmUJlF74j2Qj8GMOq3CDLX/Viak8Q=
+github.com/caarlos0/env/v11 v11.0.0/go.mod h1:2RC3HQu8BQqtEK3V4iHPxj0jOdWdbPpWJ6pOueeU1xM=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
+golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
diff --git a/html/login.tmpl.html b/html/login.tmpl.html
new file mode 100644
index 0000000..37a57de
--- /dev/null
+++ b/html/login.tmpl.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+ {{ .Title }}
+
+
+
+
+ {{ .Title }}
+ {{ if .Error }}
+
+ {{ .Error }}
+
+ {{ end }}
+
+
+
+
\ No newline at end of file
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..01c4a09
--- /dev/null
+++ b/main.go
@@ -0,0 +1,353 @@
+package main
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/caarlos0/env/v11"
+ "github.com/golang-jwt/jwt/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// config contains all options why may be configured via environment variables.
+// See the README for more details.
+var config struct {
+ CookieName string `env:"COOKIE_NAME,notEmpty"`
+
+ JWTValidity time.Duration `env:"JWT_VALIDITY" envDefault:"1h"`
+ JWTSecret string `env:"JWT_SECRET,notEmpty,unset"`
+ JWTIssuer string `env:"JWT_ISSUER,notEmpty"`
+ JWTAudience string `env:"JWT_AUDIENCE" envDefault:"jitsi"`
+ JWTLeeway time.Duration `env:"JWT_LEEWAY" envDefault:"1m"`
+
+ JitsiURL url.URL `env:"JITSI_URL,notEmpty"`
+
+ HTMLDocRoot string `env:"HTML_DOCROOT" envDefault:"./html"`
+ HTMLTitle string `env:"HTML_TITLE" envDefault:"Jitsi Moderator Login"`
+
+ ModsFile string `env:"MODS_FILE" envDefault:"./mods.htpasswd"`
+
+ HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
+}
+
+// JitsiClaims defines the schema of the JWT generated by this program.
+// Besides the standard JWT claims, it contains two Jitsi specific claims:
+// "room" (restricting what rooms the JWT is valid for) and "context" (providing
+// additional user context, such as their moderator status).
+type JitsiClaims struct {
+ jwt.RegisteredClaims
+
+ Room string `json:"room,omitempty"`
+ Context *Context `json:"context,omitempty"`
+}
+
+// Context contains the user context
+type Context struct {
+ User User `json:"user,omitempty"`
+}
+
+// User is the user context defining if the token-presenting user should have
+// moderator rights or not
+type User struct {
+ Moderator bool `json:"moderator,omitempty"`
+}
+
+// loadTokenFromCookie loads and verifies a JWT from a HTTP cookie. If the
+// cookie and the contained JWT is valid, the raw token and its "context" claim
+// are returned. If the JWT contained in the cookie is invalid, an error is returned.
+// If no cookie was found in the HTTP request, http.ErrNoCookie is returned.
+func loadTokenFromCookie(r *http.Request) (token string, context *Context, err error) {
+ c, err := r.Cookie(config.CookieName)
+ if err != nil {
+ return "", nil, err // no cookie
+ }
+
+ err = c.Valid()
+ if err != nil {
+ return "", nil, err // invalid cookie
+ }
+
+ claims := JitsiClaims{}
+ t, err := jwt.ParseWithClaims(c.Value, &claims,
+ func(token *jwt.Token) (interface{}, error) {
+ return []byte(config.JWTSecret), nil
+ },
+ jwt.WithExpirationRequired(),
+ jwt.WithIssuedAt(),
+ jwt.WithAudience(config.JWTAudience),
+ jwt.WithIssuer(config.JWTIssuer),
+ jwt.WithLeeway(config.JWTLeeway))
+ if err != nil {
+ return "", nil, err // invalid JWT
+ }
+
+ return t.Raw, claims.Context, nil
+}
+
+// generateToken generates and signs a new JWT. With the exception of the passed
+// in context (which may be nil), all claims and the signing secret are read
+// from the global config struct.
+func generateToken(context *Context) (token string, err error) {
+ now := time.Now()
+
+ claims := JitsiClaims{
+ RegisteredClaims: jwt.RegisteredClaims{
+ Audience: []string{config.JWTAudience},
+ Issuer: config.JWTIssuer,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(config.JWTValidity)),
+ },
+ Room: "*", // valid for all Jitsi rooms
+ Context: context,
+ }
+
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return t.SignedString([]byte(config.JWTSecret))
+}
+
+// redirectToJitsi redirects the current HTTP request back to Jitsi, presenting
+// the JWT contained in rawToken if redirecting back to a room
+func redirectToJitsi(w http.ResponseWriter, r *http.Request, context *Context, rawToken string) {
+ // Jitsi base URL
+ jitsiURL := url.URL{
+ Scheme: config.JitsiURL.Scheme,
+ Host: config.JitsiURL.Host,
+ }
+
+ // If a `room` query parameter was provided to us, we construct a URL
+ // containing the JWT, allowing the user to join the room automatically.
+ // If the room parameter is empty or missing, we redirect to the welcome
+ // page and do not present a JWT token.
+ room := r.URL.Query().Get("room")
+ if room != "" {
+ // Appends room name to target URL
+ jitsiURL.Path = room
+
+ // Jitsi expects the JWT to be passed via query parameter (`?jwt=...`)
+ jitsiURL.RawQuery = url.Values{
+ "jwt": []string{
+ rawToken,
+ },
+ }.Encode()
+
+ // If a room query parameter is present, we assume the user was redirected
+ // here by clicking on the "Join" button. Once we redirect the user back
+ // to Jitsi with a valid JWT, they would have to click the "Join"
+ // button again. Since this would lead to a bad user experience (i.e. the
+ // user has to press the "Join" button twice for no apparent reason),
+ // we tell Jitsi via URL config override to skip the prejoin screen and
+ // immediately join the meeting (effectively pressing the "Join" button
+ // for them the second time).
+ jitsiURL.Fragment = `config.prejoinConfig.enabled=false`
+
+ // BUG: Prosody (Jitsi's room and user management component) seems to flap
+ // the moderator status of users providing a any JWT, i.e. temporarily
+ // granting moderator rights even to regular users without a moderator
+ // token. That moderator right is taken away immediately to users without
+ // a moderator claim in their JWT, but still causes the web interface
+ // to show a "You're now a moderator" message, even though they are not.
+ //
+ // Therefore, as a workaround, we disable the "You're now a moderator"
+ // popup if the current JWT does not grant moderator rights to the
+ // current user.
+ isModerator := context != nil && context.User.Moderator
+ if !isModerator {
+ jitsiURL.Fragment += `&config.disabledNotifications=["notify.moderator"]`
+ }
+ }
+
+ http.Redirect(w, r, jitsiURL.String(), http.StatusSeeOther)
+}
+
+// htpasswd is a map containing usernames as key and bcrypt hashes as values
+type htpasswd map[string]string
+
+// loadHTPasswd parses a htpasswd formatted file and returns a map of users
+// with their corresponding password hash.
+func loadHTPasswd(file string) (htpasswd, error) {
+ f, err := os.Open(file)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ lineNo := 0
+ h := htpasswd{}
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ lineNo++
+
+ // Skip empty lines or lines starting with `#` (i.e. comments)
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ // Expects every line to be formatted as `user:hash`
+ user, hash, ok := strings.Cut(line, ":")
+ if !ok {
+ return nil, fmt.Errorf("invalid htpasswd file (line %d): %s", lineNo, file)
+ }
+ h[user] = hash
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ return h, nil
+}
+
+// isAuthenticated returns true if (and only if) the provided username and
+// password match an entry in the htpasswd file
+func (h htpasswd) isAuthenticated(username, password string) bool {
+ expectedPass, ok := h[username]
+ if !ok {
+ return false
+ }
+
+ err := bcrypt.CompareHashAndPassword([]byte(expectedPass), []byte(password))
+ return err == nil
+}
+
+// loginPage renders a HTML login page. An optional error message
+// and HTTP status code can be passed in (e.g. if the previous login failed).
+func loginPage(w http.ResponseWriter, error string, code int) {
+ // Load the HTML template file from the doc root folder
+ t, err := template.ParseFiles(path.Join(config.HTMLDocRoot, "login.tmpl.html"))
+ if err != nil {
+ slog.Error("failed to parse template", slog.Any("error", err))
+ http.Error(w, "failed to parse template", http.StatusInternalServerError)
+ return
+ }
+
+ // Render the template, passing in the .Error and .Title values
+ w.WriteHeader(code)
+ err = t.Execute(w, struct {
+ Title string
+ Error string
+ }{
+ Title: config.HTMLTitle,
+ Error: error,
+ })
+ if err != nil {
+ slog.Error("failed to render template", slog.Any("error", err))
+ return
+ }
+}
+
+// modLogin processes a moderator login request. We expect a HTTP POST request
+// containing a "username" and "password" form value. Those credentials are
+// checked against to the moderator file.
+// If the credentials are valid, a JWT with a "Moderator" claim is
+// generated, which Jitsi's `token_affiliation` plugin will use to grant
+// moderator rights to the current user.
+// The generated token is stored in a cookie, which is presented whenever the
+// autologin callback is invoked by Jitsi. This ensures moderators do not have
+// to provide their password again as long they still have a valid JWT.
+func modLogin(w http.ResponseWriter, r *http.Request) {
+ // Load moderator password file
+ mods, err := loadHTPasswd(config.ModsFile)
+ if err != nil {
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ slog.Error("failed to load htpasswd", slog.Any("error", err))
+ return
+ }
+
+ // Check for valid HTTP POST request
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request: %s", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Check username and password against mods map
+ user, pass := r.PostFormValue("username"), r.PostFormValue("password")
+ if !mods.isAuthenticated(user, pass) {
+ loginPage(w, "invalid username or password", http.StatusUnauthorized)
+ return
+ }
+
+ // User is authenticated, therefore we generate a token with a "Moderator"
+ // context claim
+ slog.Info("Generating moderator token", slog.String("user", user))
+ context := &Context{User: User{Moderator: true}}
+ token, err := generateToken(context)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("unable to generate token: %s", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Store the token in a cookie. This cookie is valid for the same duration
+ // as the JWT. The cookie is read by the autologin callback to skip the
+ // creation of a regular user JWT in favor of this higher-privilege
+ // moderator JWT
+ http.SetCookie(w, &http.Cookie{
+ Name: config.CookieName,
+ Value: token,
+ Path: "/autologin",
+ MaxAge: int(config.JWTValidity.Seconds()),
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ redirectToJitsi(w, r, context, token)
+}
+
+// autoLogin is the callback invoked by Jitsi whenever it requires a JWT
+// for the current user
+func autoLogin(w http.ResponseWriter, r *http.Request) {
+ // If the user previously logged in as a moderator, we stored their JWT
+ // token in a cookie. If the cookie and the contained JWT are both still
+ // valid, we present that JWT to Jitsi.
+ // If there is no cookie or the presented token is invalid,
+ // we generate a new regular user token without moderator permission.
+ token, context, err := loadTokenFromCookie(r)
+ if err != nil {
+ if !errors.Is(err, http.ErrNoCookie) {
+ slog.Info("failed validate cookie token", slog.Any("error", err), slog.Any("url", r.URL))
+ }
+
+ // No valid cookie was presented, generate a regular user token
+ context = nil
+ token, err = generateToken(context)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("unable to generate token: %s", err), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ redirectToJitsi(w, r, context, token)
+}
+
+func main() {
+ // Read configuration values from environment variables
+ if err := env.Parse(&config); err != nil {
+ slog.Error("failed to parse env variables", slog.Any("error", err))
+ os.Exit(1)
+ }
+
+ // HTTP endpoints and server
+ mux := http.NewServeMux()
+ mux.Handle("POST /login", http.HandlerFunc(modLogin))
+ mux.Handle("GET /login", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ loginPage(w, "", http.StatusOK)
+ }))
+ mux.Handle("GET /autologin", http.HandlerFunc(autoLogin))
+
+ slog.Info("starting HTTP server", "addr", config.HTTPAddr)
+ err := http.ListenAndServe(config.HTTPAddr, mux)
+ if !errors.Is(err, http.ErrServerClosed) {
+ slog.Error("listener failed", slog.Any("error", err))
+ os.Exit(1)
+ }
+}