Secure SSO portal for Plex, Jellyfin, and Emby users to access internal services, with RBAC, built-in LDAP sync, OAuth/OIDC, audit logs, backups, and a web admin console.
AuthPortal (v2.0.5)
AuthPortal is a lightweight, self-hosted authentication gateway built for Plex, Jellyfin, and Emby ecosystems. It provides a unified login experience for media-based communities and home-lab environments—issuing secure, signed sessions for use across your intranet portals and apps.
AuthPortal authenticates users directly against their connected media server accounts, seals the server tokens for reuse, and manages session lifecycle via HTTP-only cookies. Authorized users are directed to their personalized home page, while unrecognized users are served a restricted or “guest” view.
[!IMPORTANT]
Use at your own risk. This project leans on Vibe Coding practices - AI pair-programming, automated refactors, and rapid iteration. Treat releases as starting points - test, monitor, and adapt to your stack. AuthPortal remains an independent effort with no endorsement from Plex, Emby, or Jellyfin.
[!NOTE]
- Docker Hub: https://hub.docker.com/r/modomofn/auth-portal
- GitHub Repo: https://github.com/modom-ofn/auth-portal
Features
- Unified login gateway
- Secure session management
- Enterprise-ready expansion
- Lightweight deployment
- Customizable experience
- Runtime configuration & admin console
- RBAC, app entitlements, and directory mapping
admin, viewer, and user
- Optional LDAP group-to-role synchronization during LDAP sync runs
- Permission-gated portal app buttons and permission-backed OAuth scopes for downstream apps
- First-party OAuth 2.1 / OIDC
offline_access refresh rotation, RS256-signed ID tokens
- Discovery, JWKS, token, and userinfo endpoints ready for downstream apps and identity brokers
UI Preview
Table of Contents
- Admin Console & Config Store (new in v2.0.4) - LDAP Sync (new in v2.0.5) - Backups - OAuth 2.1 / OIDC Authorization Server (new in v2.0.4) - Multi-factor authentication - Plex - Jellyfin - Emby- Providers (Plex / Jellyfin / Emby)
- Security Notes
- Database
- Build & Images
- Logging
- HTTP Routes
- Frontend Bits
- How it works
- Customization
- Security best practices
- Security scans and code analysis
- AI-Assisted Development
- Contributing
- License
- Upgrade Guide (to v2.0.5)
What's New in v2.0.5
- RBAC is now built into AuthPortal: AuthPortal now has database-backed roles, permissions, role bindings, and permission middleware instead of relying only on a broad admin flag.
- Access Control admin tab: operators can create custom roles and custom permissions, assign permissions to roles, and manage manual user-role bindings from the browser.
- Permission-aware app entitlements: App Settings service buttons can require a selected custom permission, and downstream OAuth/OIDC clients can request those same permissions as scopes.
- OAuth client scope and audit improvements: OAuth Clients now uses a scope picker backed by the RBAC permission catalog, stores persistent server-side audit history, and captures admin-supplied change reasons for create/update/delete/rotate actions.
- Admin shell refresh: the console now uses a dedicated sidebar layout with section icons, a collapsible navigation rail, a light/dark/system theme picker, and a header user menu to make the expanded admin surface easier to navigate.
- LDAP Sync is now first-class in AuthPortal: the former standalone
ldap-synccompanion workflow is replaced by a built-inLDAP Syncadmin tab with persisted runtime config. - Manual and scheduled LDAP sync runs: run syncs on demand or on an hourly/daily/weekly schedule directly from the admin console, with next-run calculation and persisted run history.
- LDAP group-to-role mapping: optional LDAP group synchronization can map directory groups onto RBAC roles during sync.
- Connection validation before save/run: test LDAP connectivity, bind credentials, and Base DN reachability from the UI before committing config.
- Safe stale-entry cleanup: optional deletion of stale LDAP records now only targets entries previously marked as AuthPortal-managed under the configured Base DN.
- Improved LDAP observability and UX: per-user sync failures are logged with the affected username, completion summaries are logged per run, and the admin panel now exposes structured connection-test results, audit-history integration, and a cleaner sectioned layout.
- OpenLDAP bootstrap simplification: the old
ldap-seedhelper is no longer part of the recommended workflow because AuthPortal can create the configured Base DN when it is missing and creatable. - Dedicated Logs admin tab: admin audit history is now consolidated into a single Logs surface with tab/user filters, date sorting, and a live application log stream that admins can start, pause, or refresh on demand.
- Expanded server-side audit coverage: Access Control and Backups actions now emit persistent audit events so the Logs tab reflects real server history instead of browser-local state.
LDAP Sync
LDAP sync is built into AuthPortal in v2.0.5. Configure it in the Admin Console under LDAP Sync to run manual syncs or schedule recurring LDAP exports of authorized users.
What it does:
- Connects to LDAP with the configured host, bind DN, and password.
- Creates the configured Base DN when it is missing and creatable.
- Exports currently authorized AuthPortal users as LDAP entries.
- Supports manual sync and built-in hourly/daily/weekly scheduling.
- Records recent run history in the admin UI.
- Optionally deletes stale LDAP entries that were previously marked as managed by AuthPortal.
- Optionally maps LDAP groups to RBAC roles during sync when group sync is enabled.
- There is no longer a separate
ldap-syncservice or repo dependency in the recommended AuthPortal deployment path. - The old
ldap-seedhelper forou=usersis no longer required for the default setup. - LDAP sync configuration, change history, and schedule state now live with the rest of the AuthPortal admin/runtime config.
- Use the
Test Connectionbutton before saving or running a sync. Base DN Exists = PASSmeans the target branch is already usable.Base DN Exists = FAILandBase DN Creatable = PASSmeans AuthPortal should be able to create the branch on the first sync.Base DN Exists = FAILandBase DN Creatable = FAILmeans you need to fix the directory layout or LDAP ACLs first.- Stale deletion only applies to entries previously stamped as AuthPortal-managed.
- When LDAP group sync is enabled, configure the group search base DN, group name/member attributes, and at least one LDAP group-to-role mapping before saving.
Quick Start
1) .env
# ---------- Core ----------
POSTGRES_PASSWORD=change-me-long-random
SESSION_SECRET=change-me-32+chars-random
SESSIONCOOKIEDOMAIN=yourdomain.com
APPBASEURL=http://localhost:8089
Trusted proxy CIDR ranges for forwarded headers (comma separated; leave blank to disable)
TRUSTEDPROXYCIDRS=
Multi-factor authentication
MFA_ENABLE=1
MFA_ENFORCE=0
MFA_ISSUER=AuthPortal
Authorized page extra link (optional)
LOGINEXTRALINK_URL=/some-internal-app
LOGINEXTRALINK_TEXT=Open Internal App
Unauthorized page "Request Access" mailto link
UNAUTHREQUESTEMAIL=support@example.com
UNAUTHREQUESTSUBJECT=AuthPortal Access Request
Set 'MEDIA_SERVER=' options: plex | emby | jellyfin
MEDIA_SERVER=plex
Set 'FORCESECURECOOKIE=1' in prod; if behind TLS/NGINX with X-Forwarded-Proto use 1
FORCESECURECOOKIE=0
Force HSTS headers even if APPBASEURL is http (set to 1 when TLS terminates upstream)
FORCE_HSTS=0
Timezone (IANA name, e.g., America/New_York) used for schedules and timestamps
APP_TIMEZONE=UTC
Container timezone (usually matches APP_TIMEZONE)
TZ=UTC
32-byte base64 key (e.g., openssl rand -base64 32) (Do Not Reuse Example Below)
DATA_KEY=generate-your-own-key
Admin bootstrap (comma-separated username:email pairs)
ADMINBOOTSTRAPUSERS=admin:admin@example.com
OAuth2/OIDC signing & issuer (provide one of KEY or KEY_PATH)
OIDCSIGNINGKEYPATH=/run/secrets/oidcsigning_key.pem
OIDCSIGNINGKEY=
OIDC_ISSUER=https://auth.example.com
Optional allow-list for absolute OIDC redirect_uri hosts (comma/semicolon separated).
If unset, AuthPortal trusts hosts from each client's registered redirect URIs.
TRUSTEDREDIRECTHOSTS=
Logging # DEBUG | INFO | WARN | ERROR
LOG_LEVEL=INFO
---------- LDAP Sync (optional; point to your existing LDAP server) ----------
LDAP_HOST=ldap://ldap.example.com:389
LDAPADMINDN=cn=admin,dc=authportal,dc=local
LDAPADMINPASSWORD=change-me-strong
BASE_DN=ou=users,dc=authportal,dc=local
LDAP_STARTTLS=false
LDAPDELETESTALE_ENTRIES=false
LDAPSYNCSCHEDULE_ENABLED=false
LDAPSYNCSCHEDULE_FREQUENCY=daily
LDAPSYNCSCHEDULE_TIME=02:15
LDAPSYNCSCHEDULE_DAY=sunday
---------- Plex ----------
Optional but recommended for server-authorization checks
PLEXOWNERTOKEN=plxxxxxxxxxxxxxxxxxxxx
Either set machine id or a server name (machine id wins if both present)
PLEXSERVERMACHINE_ID=
PLEXSERVERNAME=
---------- Emby ----------
EMBYSERVERURL=http://localhost:8096
EMBYAPPNAME=AuthPortal
EMBYAPPVERSION=2.0.5
EMBYAPIKEY=
EMBYOWNERUSERNAME=
EMBYOWNERID=
-------- Jellyfin ---------
JELLYFINSERVERURL=http://localhost:8096
JELLYFINAPIKEY=
JELLYFINAPPNAME=AuthPortal
JELLYFINAPPVERSION=2.0.5
2) docker-compose.yaml
version: "3.9"
services: postgres: image: postgres:15 restart: unless-stopped environment: POSTGRES_DB: authportaldb POSTGRES_USER: authportal POSTGRESPASSWORD: ${POSTGRESPASSWORD:?set-in-.env} # reuse same flag as app LOGLEVEL: ${LOGLEVEL:-INFO} TZ: ${TZ:-UTC} command: - sh - -c - | set -e case "${LOG_LEVEL:-INFO}" in DEBUG|debug) EXTRA="-c logminmessages=debug1 -c logconnections=on -c logdisconnections=on -c log_destination=stderr" ;; INFO|info) EXTRA="-c logminmessages=info -c log_destination=stderr" ;; WARN|warn|WARNING|warning) EXTRA="-c logminmessages=warning -c log_destination=stderr" ;; ERROR|error) EXTRA="-c logminmessages=error -c log_destination=stderr" ;; *) EXTRA="-c logminmessages=warning -c log_destination=stderr" ;; esac # IMPORTANT: call the official entrypoint so initdb still runs on first boot exec docker-entrypoint.sh postgres $$EXTRA volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pgisready -U $${POSTGRESUSER} -d $${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 10 networks: [authnet]
auth-portal: image: ${AUTHPORTALIMAGE:-auth-portal:hardened-local} build: context: ./auth-portal dockerfile: Dockerfile ports: - "8089:8080" environment: # App APPBASEURL: ${APPBASEURL:-http://localhost:8089} APPTIMEZONE: ${APPTIMEZONE:-UTC} TZ: ${TZ:-UTC} TRUSTEDPROXYCIDRS: ${TRUSTEDPROXYCIDRS:-} SESSIONSECRET: ${SESSIONSECRET:?set-in-.env} SESSIONCOOKIEDOMAIN: ${SESSIONCOOKIEDOMAIN:?set-in-.env} DATAKEY: ${DATAKEY:?set-in-.env} LOGINEXTRALINKURL: ${LOGINEXTRALINKURL:-} LOGINEXTRALINKTEXT: ${LOGINEXTRALINKTEXT:-} UNAUTHREQUESTEMAIL: ${UNAUTHREQUESTEMAIL:-} UNAUTHREQUESTSUBJECT: ${UNAUTHREQUESTSUBJECT:-} FORCESECURECOOKIE: ${FORCESECURECOOKIE:-0} FORCEHSTS: ${FORCEHSTS:-0} MEDIASERVER: ${MEDIASERVER:-plex} MFAENABLE: ${MFAENABLE:-1} MFAENFORCE: ${MFAENFORCE:-0} MFAISSUER: ${MFAISSUER:-AuthPortal} OIDCSIGNINGKEYPATH: ${OIDCSIGNINGKEYPATH:-} OIDCSIGNINGKEY: ${OIDCSIGNINGKEY:-} OIDCISSUER: ${OIDCISSUER:-http://localhost:8089} LOGLEVEL: ${LOGLEVEL:-INFO}
# Admin Config ADMINBOOTSTRAPUSERS: ${ADMINBOOTSTRAPUSERS:?set-in-.env}
# DB DATABASEURL: postgres://authportal:${POSTGRESPASSWORD:?set-in-.env}@postgres:5432/authportaldb?sslmode=disable
# LDAP Sync LDAPHOST: ${LDAPHOST:-ldap://ldap.example.com:389} LDAPADMINDN: ${LDAPADMINDN:-cn=admin,dc=authportal,dc=local} LDAPADMINPASSWORD: ${LDAPADMINPASSWORD:-} BASEDN: ${BASEDN:-ou=users,dc=authportal,dc=local} LDAPSTARTTLS: ${LDAPSTARTTLS:-false} LDAPDELETESTALEENTRIES: ${LDAPDELETESTALEENTRIES:-false} LDAPSYNCSCHEDULEENABLED: ${LDAPSYNCSCHEDULEENABLED:-false} LDAPSYNCSCHEDULEFREQUENCY: ${LDAPSYNCSCHEDULEFREQUENCY:-daily} LDAPSYNCSCHEDULETIME: ${LDAPSYNCSCHEDULETIME:-02:15} LDAPSYNCSCHEDULEDAY: ${LDAPSYNCSCHEDULEDAY:-sunday}
# Plex PLEXOWNERTOKEN: ${PLEXOWNERTOKEN:-} PLEXSERVERMACHINEID: ${PLEXSERVERMACHINEID:-} PLEXSERVERNAME: ${PLEXSERVERNAME:-} # Jellyfin JELLYFINSERVERURL: ${JELLYFINSERVERURL:-http://localhost:8096} JELLYFINAPIKEY: ${JELLYFINAPIKEY:-} JELLYFINAPPNAME: ${JELLYFINAPPNAME:-AuthPortal} JELLYFINAPPVERSION: ${JELLYFINAPPVERSION:-2.0.5}
# Emby EMBYSERVERURL: ${EMBYSERVERURL:-http://localhost:8096} EMBYAPPNAME: ${EMBYAPPNAME:-AuthPortal} EMBYAPPVERSION: ${EMBYAPPVERSION:-2.0.5} EMBYAPIKEY: ${EMBYAPIKEY:-} EMBYOWNERUSERNAME: ${EMBYOWNERUSERNAME:-} EMBYOWNERID: ${EMBYOWNERID:-} depends_on: postgres: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD-SHELL", "wget -qO- --header='Host: 127.0.0.1' http://127.0.0.1:8080/healthz >/dev/null || exit 1"] interval: 30s timeout: 3s start_period: 20s retries: 3 networks: [authnet]
volumes: pgdata:
networks: authnet:
3) Run
docker compose up -d --build Visit http://localhost:8089
If you plan to use LDAP Sync, point the LDAP environment variables at your existing LDAP implementation, then open Admin -> LDAP Sync in AuthPortal to save connection settings, run a manual sync, or enable the built-in scheduler.
Configuration
APPBASEURLexternal URL users hit (drives redirects & cookie flags). Use HTTPS in production.SESSIONCOOKIEDOMAINdomain scope for session + pending-MFA cookies (e.g.,auth.example.com).MEDIA_SERVERplex,jellyfin, oremby.SESSION_SECRETHMAC secret for the session JWT cookie (required, 32+ random bytes; the service refuses to start if unset or using the legacy default).SESSION_TTLauthorized-session lifetime as a Go duration (default24h).SESSION_SAMESITEcookie SameSite mode:lax(default),strict, ornone(nonerequires HTTPS/Secure cookies).DATA_KEYbase64 32-byte key for sealing provider tokens at rest (required).MFAENABLE/MFAENFORCE/MFA_ISSUERmulti-factor toggles; see below.FORCESECURECOOKIEset to1to forceSecureon cookies (behind TLS/ingress).FORCEHSTSset to1to always emit Strict-Transport-Security even ifAPPBASE_URLis http (use when TLS terminates upstream).APPTIMEZONEIANA timezone (e.g.,America/NewYork) used for backup scheduling and admin timestamps; setTZto the same value in Docker to keep the container clock aligned.TRUSTEDPROXYCIDRScomma-separated CIDR ranges of proxies allowed to supplyX-Forwarded-For/X-Real-IP; leave empty to rely onRemoteAddr.TRUSTEDREDIRECTHOSTSoptional comma/semicolon-separated host allow-list for absolute OIDCredirect_urivalues; if unset, allowed hosts derive from each client's registered redirect URIs.LOGINEXTRALINK_URLexternal URL on authorized page.LOGINEXTRALINK_TEXTtext for that authorized-page link.UNAUTHREQUESTEMAILemail address for unauthorized page "Request Access" mailto.UNAUTHREQUESTSUBJECTsubject for the unauthorized-page mailto link.BACKUP_DIRfilesystem path inside the container for generated config backups (default./app/backupsrelative to the binary).LDAPDELETESTALE_ENTRIESwhen set totrue, scheduled or manual LDAP syncs may delete stale entries previously marked as AuthPortal-managed under the configured LDAP base DN.LDAPSYNCSCHEDULEENABLED/LDAPSYNCSCHEDULEFREQUENCY/LDAPSYNCSCHEDULETIME/LDAPSYNCSCHEDULEDAYbootstrap defaults for the built-in LDAP scheduler; once saved in Admin, the persisted runtime config takes precedence.LOG_LEVELDEBUG,INFO,WARN, orERROR.
Admin Console & Config Store (new in v2.0.4)
- Reach the admin experience at
/adminwith a user provisioned viaADMINBOOTSTRAPUSERS(comma-separatedusername:emailpairs evaluated at startup). - Providers, Security, MFA, App Settings, and LDAP Sync settings now persist in Postgres as JSON documents. Edits go through
/api/admin/config/{section}with optimistic concurrency (versionfield) and are tracked in/api/admin/config/history/{section}. - The admin shell now includes a persistent sidebar, collapse toggle, theme selection (
system,dark,light), and header user menu to keep the expanded multi-tab console manageable on desktop and smaller screens. - Each save accepts an optional change reason and appends to the audit log. Use the Refresh button to pull the latest runtime config before editing if multiple admins are active.
- The OAuth tab in the admin console surfaces live client management (list/create/update/delete plus secret rotation), persistent audit history, and change reasons backed by the
/api/admin/oauth/*endpoints. - The Access Control tab exposes RBAC role, permission, and manual binding management backed by the
/api/admin/rbac/*endpoints.
LDAP Sync (new in v2.0.5)
- The LDAP Sync tab under
/adminmanages LDAP host/bind/Base-DN settings, StartTLS, optional stale-entry deletion, manual sync runs, and the built-in scheduler. - Optional group sync in the same tab can map LDAP groups to AuthPortal RBAC roles.
- The
Test Connectionaction validates connect, bind, Base DN existence, and Base DN creatability before you save or sync. - Scheduled runs support
hourly,daily, andweeklytiming directly in the UI and are reflected in theNext Scheduled Runpanel state. - Recent sync history includes manual and scheduled runs with trigger source, timestamps, result status, entry counts, and summary/error output.
- LDAP sync config changes participate in the same config history and consolidated audit workflow as the other admin sections.
Backups
- The Backups tab under
/adminlets you export the current config documents on demand (Run Backup) or configure an automatic schedule (hourly/daily/weekly with retention and section filters). - Backup files are JSON blobs stored under
BACKUP_DIR(default./app/backupsbeside the binary) and include metadata such as author, timestamp, and which sections were captured. - Scheduled backup settings now live in the config store (section
backups), so your cadence, selected sections, and retention persist across container rebuilds and are auditable like other config updates. - Each row in the table supports
Download,Restore, andDelete. Restore immediately applies the captured config via the standard validation pipeline; deletion only affects the filesystem. - The same functionality is exposed via the REST API (
/api/admin/backups*); see HTTP Routes below for endpoint details.
OAuth 2.1 / OIDC Authorization Server (new in v2.0.4)
- Discovery endpoint
/.well-known/openid-configurationadvertises JWKS (/oidc/jwks.json), authorize (/oidc/authorize), token (/oidc/token), and userinfo (/oidc/userinfo) URLs. /oidc/authorizeimplements the authorization-code grant with PKCE. User consent is recorded per client/scope, supportsprompt=consent, and returnsconsent_requiredwhenprompt=noneis requested without prior approval.- If a portal session is missing,
/oidc/authorizeis resumed after login (and MFA when required) using a validated localnextcontinuation. - Client
redirect_urivalidation is exact-string against the registered redirect URI list (scheme/host/path/query must match exactly). - OAuth error responses redirect back to valid absolute callback URLs (
http/https) with standarderrorquery parameters. /oidc/tokenhandlesauthorizationcodeandrefreshtokengrants. Refresh tokens rotate on every use and are only issued when theoffline_accessscope is granted./oidc/userinforeturnssub,preferred_username, and optional email claims based on granted scopes. ID tokens are RS256-signed and echo the incomingnonce.- Provide signing material with
OIDCSIGNINGKEYPATH(PEM on disk) or inlineOIDCSIGNINGKEY; override the advertised issuer withOIDCISSUERwhen running behind a reverse proxy. - Register clients through the admin console (OAuth tab) or the REST API:
GET/POST /api/admin/oauth/clients,PUT/DELETE /api/admin/oauth/clients/{id},POST /api/admin/oauth/clients/{id}/rotate-secret,GET /api/admin/oauth/scopes, andGET /api/admin/oauth/history. - Custom RBAC permissions can be used as OAuth scopes, which lets downstream apps request app-specific entitlements such as
audiobookshelf.read.
RBAC / Access Control (new in v2.0.5)
- AuthPortal now stores roles, permissions, role-permission assignments, and user-role bindings in Postgres.
- Seeded system roles are
admin,viewer, anduser; system permissions protect core admin surfaces such as config, LDAP, backups, OAuth, and RBAC management. - The Access Control tab under
/adminlets operators create custom permissions and custom roles, assign permissions to roles, and manage manual role bindings for users. - App Settings service links can require a selected permission. If no permission is selected, the button is shown to all authorized users.
/whoamiand/menow expose the caller's effective roles and permissions for downstream integrations.
Admin Logs
- The Logs tab under
/adminreplaces the older per-tab Recent Changes sidebar/modal with a single consolidated audit view. - Audit history is aggregated across config sections, OAuth client management, Access Control actions, backup actions, and LDAP Sync-related config history.
- Operators can filter the table by admin tab and acting user, then sort by change date to review older or newer activity first.
- The live log stream is off by default and only starts polling after an admin explicitly starts it.
- While streaming, admins can choose an auto-refresh interval; when paused, they can use manual refresh to poll the latest buffered entries.
Multi-factor authentication
MFA_ENABLEcontrols whether users can enroll; leave it1when enforcing.MFA_ENFORCEforces every login to satisfy MFA once a user is enrolled (or immediately when set globally).MFA_ISSUERcustomises the label your authenticator app displays and the recovery code download header.- Enrollment lives under
/mfa/enroll/*; challenges use/mfa/challengeand/mfa/challenge/verify. Recovery codes rotate on each successful verify.
Plex
PLEXSERVERMACHINE_IDpreferred; exact machine identifier of your server.PLEXSERVERNAMEfallback if machine id not set.PLEXOWNERTOKENoptional owner token. If configured, the owner account is always authorized (account id match).
Jellyfin
JELLYFINSERVERURLe.g.,http://<host>:8096.
localhost).
JELLYFINAPIKEYoptional; enables stricter authorization checks (IsDisabledpolicy).JELLYFINAPPNAME,JELLYFINAPPVERSIONclient headers used in requests.
Emby
EMBYSERVERURLe.g.,http://<host>:8096.
localhost).
EMBYAPIKEYoptional; enables stricter authorization checks (IsDisabledpolicy).EMBYAPPNAME,EMBYAPPVERSIONclient headers used in requests.
Providers (Plex / Jellyfin / Emby)
- Plex:
StartWeb creates a PIN and returns the Plex Auth URL popup opens.
Forward polls the PIN, fetches user info, seals token, decides authorization:
1. User token can see configured server in /api/v2/resources (match machine id or name), OR
2. Owner fallback if PLEXOWNERTOKEN is set and account ids match.
- Jellyfin:
StartWeb returns /auth/forward?jellyfin=1.
Forward (GET) serves a small login page; (POST) authenticates, seals token, validates the user token (/Users/Me), then (optionally) overlays admin policy via JELLYFINAPIKEY (IsDisabled).
- Emby:
StartWeb returns /auth/forward?emby=1.
Forward (GET) serves a small login page; (POST) authenticates, seals token, and optionally checks IsDisabled via EMBYAPIKEY.
All providers implement IsAuthorized(uuid, username); success is cached in media_access.
Security Notes
- Token sealing: tokens are encrypted with
DATA_KEYbefore DB insert/update. Unseal on read; failures clear the in-memory token. - Cookies: Session and pending-MFA cookies honour
SESSIONCOOKIEDOMAIN; they are HTTP-only, SameSite=Lax, and rotate after successful MFA.Secureis automatic whenAPPBASEURLis HTTPS, or force it withFORCESECURECOOKIE=1. - Authorization: admin and downstream-app access is enforced through RBAC permissions, not just a legacy admin boolean. Custom app permissions can gate portal links and OAuth scopes.
- Admin observability: the Logs tab is permission-gated behind admin access, and its audit table is backed by persisted server audit records for OAuth, Access Control, backups, and config changes.
- Rate limits: login endpoints share a per-IP limiter (burst 5, ~10 req/min); MFA enrollment/challenge use a tighter burst 3, ~5 req/min (tune in
main.go). - CSRF-lite: POST routes require same-origin via Origin/Referer.
- Headers:
X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin.
Adds Strict-Transport-Security: max-age=86400; includeSubDomains; preload when APPBASEURL is HTTPS.
Popup/auth completion pages keep a narrowed CSP with script-src 'self'; popup handoff is performed by static/login.js rather than inline script.
Database
Users table (legacy-compatible):
id BIGSERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT NULL,
media_uuid TEXT UNIQUE,
media_token TEXT NULL,
media_access BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
Indexes:
CREATE INDEX IF NOT EXISTS idxusersusername ON users (username);
CREATE INDEX IF NOT EXISTS idxusersmediauuid ON users (mediauuid);
Plex PINs table (unchanged):
CREATE TABLE IF NOT EXISTS pins (
code TEXT PRIMARY KEY,
pin_id INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Identities
- Purpose: multi-provider identity linking (one row per provider per user).
- Columns:
userid (FK),provider,mediauuid,mediatoken,mediaaccess, timestamps. - Uniqueness:
(provider, mediauuid),(userid, provider). - Backfill: on startup, rows are inserted from
users.media_uuidwhen present. - Writes: app upserts into both
usersandidentities(transition-friendly). - Reads: prefer
identitiesthen fallback touserswhere needed.
RBAC tables
roles: role catalog with seeded system roles and custom roles.permissions: permission catalog containing system permissions plus admin-defined custom permissions.role_permissions: many-to-many mapping between roles and permissions.user_roles: user-role assignments, including manual and LDAP-synchronized sources.
admin role during upgrade.
Build & Images
- Go:
1.26.4on Docker Hardened Imagesdhi.io/golang:1.26.4-alpine3.23-dev(builder stage). - Builder installs
git+ CA certs, runsgo mod downloadthengo mod tidy -compat=1.26, builds with:
-v -x (verbose), -buildvcs=false (avoid VCS scans), -trimpath, -ldflags "-s -w".
- Builder:
dhi.io/golang:1.26.4-alpine3.23-dev. - Runtime:
dhi.io/alpine-base:3.23-alpine3.23, keeps CA certs in-base, copies tzdata from the builder stage, and runs as non-rootuid 65532.
Logging
- App:
LOG_LEVEL=DEBUG|INFO|WARN|ERROR.
DEBUG jellyfin/auth POST http://<server>/Users/AuthenticateByName?format=json
WARN emby/auth HTTP 401 body="..."
DEBUG plex: resources match via machine id
- Admin Logs tab:
Logs tab.
- The live stream viewer reads from an in-memory recent log buffer exposed only to authenticated admins.
- Streaming is opt-in and disabled by default; admins can start, pause, or manually refresh the view.
- Postgres:
LOG_LEVELmaps to server params:
DEBUG logminmessages=debug1, connection/disconnection logging on
- INFO logminmessages=info
- WARN logminmessages=warning
- ERROR logminmessages=error
HTTP Routes
- Core portal
GET / login page (auto-redirects to /home if session present).
- GET /home renders authorized or unauthorized view based on IsAuthorized.
- GET /whoami JSON: normalized identity, session metadata, effective roles, and effective permissions.
- GET /me JSON profile summary including username, uuid, roles, and permissions when logged in.
- POST /logout clears cookies; same-origin required.
- GET /static/* static assets.
- Authentication
POST /auth/start-web JSON { authUrl } (per-IP rate limited).
- Plex: returns the Plex auth URL for the PIN flow.
- Jellyfin/Emby: returns /auth/forward?jellyfin=1 or /auth/forward?emby=1.
- GET|POST /auth/forward popup finisher for all providers.
- Plex: completes PIN polling and closes the popup.
- Jellyfin/Emby: GET serves the form; POST authenticates and closes the popup.
- GET /auth/poll Plex PIN poller (rate limited, JSON { status }).
- Multi-factor authentication
GET /mfa/challenge HTML challenge page shown when MFA is required.
- POST /mfa/challenge/verify JSON { ok, redirect, recoveryUsed, remainingRecoveryCodes }; accepts optional next for OIDC continuation and rotates the session cookie on success.
- GET /mfa/enroll HTML enrollment UI for authenticated users.
- GET /mfa/enroll/status JSON summary of enrollment state (enabled/pending timestamps, remaining recovery codes).
- POST /mfa/enroll/start JSON { ok, secret, otpauth, digits, period, drift, enforced, previouslyEnabled } to seed authenticator apps.
- POST /mfa/enroll/verify JSON { ok, recoveryCodes } confirming enrollment and returning fresh recovery codes.
- OAuth 2.1 / OIDC
GET /.well-known/openid-configuration discovery document.
- GET /oidc/jwks.json JWKS for RS256 validation.
- GET /oidc/authorize authorization-code endpoint; if not authenticated, redirects to login and resumes via next.
- POST /oidc/authorize/decision consent form postback (allow/deny).
- POST /oidc/token token exchange for authorization code + refresh grants.
- GET /oidc/userinfo userinfo endpoint (bearer token required).
- Admin console & APIs
GET /admin admin SPA for bootstrap/admin users.
- GET /api/admin/config returns the admin configuration bundle, including Providers, Security, MFA, App Settings, and LDAP Sync.
- PUT /api/admin/config/{section} update a configuration section with optimistic concurrency.
- GET /api/admin/config/history/{section} fetch prior revisions.
- GET /api/admin/config/permissions list custom permissions allowed in App Settings service-link gating.
- GET /api/admin/ldap-sync fetch LDAP sync config, scheduler status, and recent runs.
- POST /api/admin/ldap-sync/test-connection validate LDAP connectivity and Base DN readiness using the submitted form values without saving them.
- POST /api/admin/ldap-sync/run trigger a manual LDAP sync run.
- GET /api/admin/logs/history fetch consolidated admin audit history across supported tabs.
- GET /api/admin/logs/stream fetch the buffered live application log stream for the admin Logs tab.
- GET /api/admin/oauth/clients list registered OAuth clients.
- GET /api/admin/oauth/history fetch persistent OAuth audit history.
- GET /api/admin/oauth/scopes list standard OIDC scopes plus custom RBAC permission scopes.
- POST /api/admin/oauth/clients create a new client.
- PUT /api/admin/oauth/clients/{id} update client metadata.
- DELETE /api/admin/oauth/clients/{id} delete a client.
- POST /api/admin/oauth/clients/{id}/rotate-secret rotate client secret and return the new value.
- GET /api/admin/rbac fetch roles, permissions, and current bindings.
- PUT /api/admin/rbac/bindings create or replace manual user-role bindings.
- POST /api/admin/rbac/roles create a custom role.
- PUT /api/admin/rbac/roles/{name} update a role definition.
- DELETE /api/admin/rbac/roles/{name} delete a custom role.
- POST /api/admin/rbac/permissions create a custom permission.
- PUT /api/admin/rbac/permissions/{name} update a custom permission.
- DELETE /api/admin/rbac/permissions/{name} delete a custom permission.
- GET /api/admin/backups return the current schedule metadata plus available backup files.
- POST /api/admin/backups create a manual backup for the selected sections.
- PUT /api/admin/backups/schedule update the automated backup schedule (frequency, sections, retention).
- GET /api/admin/backups/{name} download a specific backup file.
- DELETE /api/admin/backups/{name} remove a backup file from storage.
- POST /api/admin/backups/{name}/restore restore captured config sections from a saved backup.
- Health & readiness
GET /healthz liveness check.
- GET /readyz readiness (DB connectivity).
- GET /startupz startup probe sharing the same checks as readiness.
Frontend Bits
- Styles:
static/styles.css(icons clamped to 22"22 inside the sign-in button) - Login script:
static/login.js
postMessage types: plex-auth, emby-auth, jellyfin-auth, auth-portal.
- If the popup is closed/blocked, falls back to full-page nav.
- Binds via id="auth-signin" / [data-auth-signin] / .auth-signin
How it works
High-level- User clicks Sign in with Plex/Emby/Jellyfin; frontend opens the provider popup.
- Server completes provider-specific auth, seals/stores the media token, and decides authorization.
- If MFA is required (enforcement on or the user has enabled it), the app issues a pending-MFA cookie and redirects to
/mfa/challenge; otherwise it sends the user directly to/home. - If login originated from
/oidc/authorize, AuthPortal preserves that request and resumes it after login; MFA challenge completion also resumes the same authorize URL. - The MFA challenge verifies a TOTP or recovery code, rotates the JWT, clears the pending cookie, and redirects to the validated continuation target (or
/home). - Session cookie TTL defaults to 24h for authorized users and 5m for unauthorized; authorized user profiles are stored in Postgres.
- The opener page updates based on authorization, showing the authorized or restricted home experience and optional extra links.
Customization
- Branding and portal styling: use Admin -> App Settings to set the end-user portal app name, logo URL, background color, modal color, title color, body text color, and optional custom background image.
- Custom background image: set
portalBackgroundUrlto a relative path such as/static/portal-background.jpgor an absolutehttp(s)URL. When this is set, it overrides onlyportalBackgroundColor; modal, title, body text, and service button colors continue to apply. - Background display mode: set
portalBackgroundModetospan,fit,centered,original,stretch, ortile.spanis the default and fills the viewport while cropping as needed;fitkeeps the whole image visible;centereduses the image's natural size centered in the viewport;originaluses natural size from the top-left;stretchforces the image to the viewport;tilerepeats it. - Environment defaults: first-run/default values can be supplied with
PORTALAPPNAME,PORTALLOGOURL,PORTALBACKGROUNDCOLOR,PORTALBACKGROUNDURL,PORTALBACKGROUNDMODE,PORTALMODALCOLOR,PORTALTITLECOLOR, andPORTALBODYTEXT_COLOR. Once App Settings are saved in Postgres, manage ongoing changes through the admin UI or/api/admin/config/app-settings. - Authorized / Unauthorized page copy: edit the text fields in Admin -> App Settings -> Portal Config. The copy fields support
{{username}},{{providerName}}, and{{appName}}.
Security best practices
- Put AuthPortal behind HTTPS (e.g., Caddy / NGINX / Traefik).
- Set strong
SESSIONSECRET(startup now fails if it's missing/short),DATAKEY, and DB credentials. - OAuth client secrets are hashed with bcrypt before storage; rotate legacy secrets so they’re re-hashed and unusable if the DB or backups leak.
- Access and refresh tokens are stored as deterministic SHA-256 digests, so leaked database rows don’t expose bearer tokens (rotate outstanding tokens after upgrading).
- Config backups written to disk are encrypted with the same
DATA_KEY, so keep that key secret and re-bootstrap older plaintext backups if needed. - The admin Logs tab can expose recent operational log lines in-browser to authenticated admins, so avoid logging secrets and keep admin access tightly scoped.
- Admin flag changes immediately revoke outstanding sessions by bumping an internal session version; reissue cookies after any privilege change.
- Dont expose Postgres or LDAP externally unless necessary.
- Keep images and dependencies updated.
- Enforce MFA everywhere by setting
MFAENABLE=1andMFAENFORCE=1; the code already backstopsMFA_ENABLEwhen enforcement is on. - If the portal is only used for same-origin apps, consider
SESSION_SAMESITE=strict; usenoneonly with HTTPS because Secure cookies are required. - Keep rate limits aligned with your threat model; newIPRateLimiter accepts tighter limits if you need to clamp brute force attempts (rate_limiter.go:10-74).
Security scans and code analysis
Automated security checks run on this project:
- Syft SBOM + Grype: SBOM generated from the built image; Grype scans that SBOM.
- TruffleHog: secret scanning on every push/PR in CI/CD.
- git-secrets: local pre-commit and commit-message guardrail to catch credentials before they land in git history.
- GitHub CodeQL: static analysis for code-level vulnerabilities in every PR and on main.
- Trivy: container and dependency scans to catch OS and library CVEs in our images.
- Docker Scout: image-level vulnerability insights for each commit/tag, including base image and layer analysis.
- SonarQube Cloud: continuous code quality and security hotspot detection across the codebase.
Local secret scanning (git-secrets)
Install git-secrets, then bootstrap the repo hooks:
brew install git-secrets
./scripts/install-git-secrets-hooks.sh
git secrets --scan-history
On Windows PowerShell:
.\scripts\install-git-secrets-hooks.ps1
git secrets --scan-history
The bootstrap scripts install git-secrets hooks for this repository and register AWS patterns plus a small set of generic credential patterns for passwords, tokens, API keys, and private keys.
AI-Assisted Development
AuthPortal is developed with AI assistance as part of the delivery workflow, but not as a substitute for engineering judgment. I currently use OpenAI Codex to help with core framework and backend work, and Claude Code to help accelerate frontend interface work.
That said, I remain responsible for every change that lands in this repository. AI-generated code is treated as a draft, not an authority.
What that means in practice:
- I review all generated changes before they are committed or pushed.
- I test changes locally before publishing updates.
- I validate behavior against the actual application flow, not just the generated diff.
- I use automated scanning and analysis to catch issues early, but I do not rely on automation alone.
- I will revise or reject AI-generated output when it does not meet the project standard.
If you are cautious about AI use in open source, that caution is reasonable. The goal here is to be transparent about the workflow: AI is used to speed delivery, while review, testing, and release responsibility stay with me.
Contributing
Issues and PRs welcome: https://github.com/modom-ofn/auth-portal/issues
License
GPL-3.0 https://opensource.org/license/gpl-3-0/
[!IMPORTANT]
Use at your own risk. This project leans on Vibe Coding practices - AI pair-programming, automated refactors, and rapid iteration. Treat releases as starting points - test, monitor, and adapt to your stack. AuthPortal remains an independent effort with no endorsement from Plex, Emby, or Jellyfin.
Upgrade Guide (to v2.0.5)
1) Rebuild or pull modomofn/auth-portal:v2.0.5 so you pick up the RBAC, LDAP Sync, and admin audit improvements. 2) If you previously used the standalone ldap-sync workflow, migrate that configuration into Admin -> LDAP Sync and stop relying on the external repo/service. 3) If your compose/docs still reference ldap-seed for ou=users, remove that dependency unless you intentionally seed extra LDAP structure outside AuthPortal-managed sync. 4) Review the new Access Control model: - Existing legacy admin users are backfilled into the seeded admin role. - Use Admin -> Access Control to manage custom roles, custom permissions, and manual user-role bindings. - If you expose downstream apps through App Settings or OAuth, create app-specific permissions first and assign them through roles. 5) Review LDAP behavior before enabling stale deletion or group-role synchronization: - Leave Delete stale AuthPortal-managed LDAP entries off until at least one successful built-in sync has updated the entries you want AuthPortal to own. - Verify the bind account has permission to create entries under the configured Base DN. - If you enable LDAP group sync, verify the group search base DN, name/member attributes, and group-to-role mappings first. 6) Set SESSIONCOOKIEDOMAIN to the host you serve AuthPortal from (e.g., auth.example.com) so session + pending-MFA cookies survive redirect flows. 7) Decide on MFA posture: - Leave MFA_ENABLE=1 to let users enroll. - Flip MFAENFORCE=1 if everyone must pass MFA on login; keep MFAENABLE=1 in that case. 8) Verify end-to-end: - Existing users can log in, enroll, and download recovery codes. - Enforced logins reach /mfa/challenge and succeed with both TOTP codes and a recovery code. - Repeated bad logins or code attempts return HTTP 429 from the per-IP rate limiters. - LDAP Test Connection passes, a manual LDAP sync succeeds, and scheduled runs appear in Recent Sync Runs when enabled. - Access Control role assignments behave as expected for admin surfaces and app-specific entitlements. - OAuth clients can request intended custom permission scopes and their audit history records the admin-supplied reason.