diff --git a/go.mod b/go.mod
index c933d52..ab06244 100644
--- a/go.mod
+++ b/go.mod
@@ -28,11 +28,13 @@ require (
 	github.com/bytedance/sonic/loader v0.2.4 // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cloudwego/base64x v0.1.5 // indirect
+	github.com/coreos/go-oidc/v3 v3.21.0
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/gin-contrib/sse v1.0.0 // indirect
 	github.com/go-ini/ini v1.67.0 // indirect
+	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
 	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/go-openapi/jsonpointer v0.21.1 // indirect
 	github.com/go-openapi/jsonreference v0.21.0 // indirect
@@ -99,6 +101,7 @@ require (
 	go.uber.org/mock v0.3.0 // indirect
 	golang.org/x/mod v0.25.0 // indirect
 	golang.org/x/net v0.41.0 // indirect
+	golang.org/x/oauth2 v0.36.0
 	golang.org/x/sys v0.33.0 // indirect
 	golang.org/x/text v0.26.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
diff --git a/go.sum b/go.sum
index 5f8f9f9..9ecf077 100644
--- a/go.sum
+++ b/go.sum
@@ -15,6 +15,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
 github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
 github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
 github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
+github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
 github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/danielpaulus/go-ios v1.2.1 h1:NQYfe32n8a11vVIb7iEuITugG164cbtIGlNrMDk07bE=
@@ -44,6 +46,8 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
 github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
 github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
 github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
 github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
 github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
@@ -326,6 +330,8 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
 golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
 golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
 golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/hub/auth/auth.go b/hub/auth/auth.go
index ecc038b..4f587f8 100644
--- a/hub/auth/auth.go
+++ b/hub/auth/auth.go
@@ -123,12 +123,17 @@ func LoginHandler(c *gin.Context) {
 		return
 	}
 
-	user, err := db.GlobalMongoStore.GetUser(creds.Username)
+	var user models.User
+	if Enterprise != nil {
+		user, err = EnterpriseCredentials(c, creds)
+	} else {
+		user, err = db.GlobalMongoStore.GetUser(creds.Username)
+	}
 	if err != nil {
 		api.Unauthorized(c, "Invalid credentials")
 		return
 	}
-	if user.Password != creds.Password {
+	if Enterprise == nil && (user.Password == "" || strings.HasPrefix(user.Username, "staff:") || user.Password != creds.Password) {
 		api.Unauthorized(c, "Invalid credentials")
 		return
 	}
@@ -157,6 +162,13 @@ func LoginHandler(c *gin.Context) {
 		api.InternalError(c, "Failed to generate token")
 		return
 	}
+	if Enterprise != nil {
+		cookie, _ := c.Cookie(enterpriseCookie)
+		if err := Enterprise.recordNativeToken(cookie, token, time.Now()); err != nil {
+			api.InternalError(c, "Failed to bind enterprise login")
+			return
+		}
+	}
 
 	// Response in requested format
 	api.OK(c, "", models.AuthResponse{
@@ -179,6 +191,7 @@ func LoginHandler(c *gin.Context) {
 // @Security     BearerAuth
 // @Router       /logout [post]
 func LogoutHandler(c *gin.Context) {
+	EnterpriseLogout(c)
 	// Check if there's a bearer token
 	authHeader := c.GetHeader("Authorization")
 	if strings.HasPrefix(authHeader, "Bearer ") {
@@ -211,6 +224,10 @@ func LogoutHandler(c *gin.Context) {
 // @Security     BearerAuth
 // @Router       /change-password [post]
 func ChangePasswordHandler(c *gin.Context) {
+	if Enterprise != nil {
+		api.BadRequest(c, "Manage passwords in Aibibu enterprise identity")
+		return
+	}
 	// Always resolve the acting user from the token/context - never from the
 	// request body - so a user can only ever change their own password.
 	username := c.GetString("username")
diff --git a/hub/auth/enterprise.go b/hub/auth/enterprise.go
new file mode 100644
index 0000000..ddeabdc
--- /dev/null
+++ b/hub/auth/enterprise.go
@@ -0,0 +1,474 @@
+package auth
+
+// Aibibu's OIDC adapter leaves the separately licensed GADS UI unchanged.
+// A short-lived, browser-bound ticket is submitted through its normal login form.
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"html/template"
+	"net/http"
+	"net/url"
+	"os"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"GADS/common/db"
+	"GADS/common/models"
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/gin-gonic/gin"
+	"go.mongodb.org/mongo-driver/mongo"
+	"golang.org/x/oauth2"
+)
+
+const enterpriseCookie = "__Host-gads-staff"
+const flowCookie = "__Host-gads-flow"
+const ticketUsername = "aibibu-oidc-ticket"
+
+type loginFlow struct {
+	binding, nonce, verifier string
+	expires                  time.Time
+}
+type enterpriseSession struct {
+	subject, ticket, nativeSessionID string
+	expires, ticketExpires           time.Time
+}
+type enterpriseLogin struct {
+	origin, ownerSubject, group string
+	oauth                       oauth2.Config
+	verifier                    *oidc.IDTokenVerifier
+	mu                          sync.Mutex
+	flows                       map[string]loginFlow
+	sessions                    map[string]enterpriseSession
+}
+
+var Enterprise *enterpriseLogin
+
+func randomOpaque() string {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		panic("secure randomness unavailable")
+	}
+	return base64.RawURLEncoding.EncodeToString(b)
+}
+func opaqueHash(s string) string {
+	sum := sha256.Sum256([]byte(s))
+	return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+// ConfigureEnterprise is called once before the server accepts requests.
+// A partially configured integration fails startup rather than enabling passwords.
+func ConfigureEnterprise() error {
+	issuer := os.Getenv("GADS_STAFF_ISSUER")
+	if issuer == "" {
+		return nil
+	}
+	origin := os.Getenv("GADS_STAFF_ORIGIN")
+	u, err := url.Parse(origin)
+	if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
+		return errors.New("invalid staff HTTPS origin")
+	}
+	clientID, owner, group := os.Getenv("GADS_STAFF_CLIENT_ID"), os.Getenv("GADS_STAFF_OWNER_SUBJECT"), os.Getenv("GADS_STAFF_GROUP")
+	secret, err := os.ReadFile(os.Getenv("GADS_STAFF_SECRET_FILE"))
+	if err != nil || len(strings.TrimSpace(string(secret))) < 32 || clientID == "" || owner == "" || group == "" {
+		return errors.New("incomplete staff OIDC configuration")
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	provider, err := oidc.NewProvider(ctx, issuer)
+	if err != nil {
+		return errors.New("staff OIDC discovery unavailable")
+	}
+	endpoint := provider.Endpoint()
+	issuerURL, _ := url.Parse(issuer)
+	for _, raw := range []string{endpoint.AuthURL, endpoint.TokenURL} {
+		ep, e := url.Parse(raw)
+		if e != nil || ep.Scheme != "https" || ep.Host != issuerURL.Host {
+			return errors.New("untrusted staff OIDC endpoint")
+		}
+	}
+	endpoint.AuthStyle = oauth2.AuthStyleInHeader
+	Enterprise = &enterpriseLogin{origin: origin, ownerSubject: owner, group: group,
+		oauth:    oauth2.Config{ClientID: clientID, ClientSecret: strings.TrimSpace(string(secret)), RedirectURL: origin + "/enterprise/callback", Endpoint: endpoint, Scopes: []string{oidc.ScopeOpenID, "profile", "email"}},
+		verifier: provider.Verifier(&oidc.Config{ClientID: clientID, SupportedSigningAlgs: []string{"RS256"}}), flows: map[string]loginFlow{}, sessions: map[string]enterpriseSession{}}
+	return nil
+}
+func setPrivateCookie(c *gin.Context, name, value string, seconds int) {
+	http.SetCookie(c.Writer, &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: seconds})
+}
+func (e *enterpriseLogin) sweepLocked(now time.Time) {
+	for key, f := range e.flows {
+		if !now.Before(f.expires) {
+			delete(e.flows, key)
+		}
+	}
+	for key, s := range e.sessions {
+		if !now.Before(s.expires) {
+			delete(e.sessions, key)
+		}
+	}
+}
+func (e *enterpriseLogin) session(c *gin.Context) (enterpriseSession, bool) {
+	cookie, err := c.Cookie(enterpriseCookie)
+	if err != nil {
+		return enterpriseSession{}, false
+	}
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.sweepLocked(time.Now())
+	s, ok := e.sessions[opaqueHash(cookie)]
+	return s, ok
+}
+
+// Provider heartbeats use the private SSH path. The public reverse proxy denies
+// /provider-update; it must never be published as an unauthenticated endpoint.
+func EnterpriseGate() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		e := Enterprise
+		if e == nil {
+			return
+		}
+		path := c.Request.URL.Path
+		if path == "/provider-update" || path == "/authenticate" || strings.HasPrefix(path, "/enterprise/") {
+			return
+		}
+		_, signedIn := e.session(c)
+		nativeFrame := c.Request.Method == http.MethodGet && c.GetHeader("Sec-Fetch-Dest") == "iframe" && c.GetHeader("Upgrade") == "" && strings.Contains(c.GetHeader("Accept"), "text/html") && (path == "/" || allowedEnterpriseUIPath(path))
+		if nativeFrame {
+			if !signedIn {
+				c.Status(http.StatusUnauthorized)
+				c.Abort()
+				return
+			}
+			// Some SPA routes (e.g. /apps) share a path with a JSON API.
+			// Only a native HTML frame navigation gets the unchanged UI index;
+			// the browser keeps its original route and API requests are untouched.
+			c.Request.URL.Path = "/"
+			c.Request.URL.RawPath = ""
+			return
+		}
+		if path == "/" && (!signedIn || c.Query("native_login") != "1") {
+			// The enterprise cookie can outlive the native GADS inactivity session.
+			// A redirect to /devices here would loop after the UI discards its JWT.
+			c.Status(http.StatusOK)
+			staffPage(c, "Aibibu 设备中控", "使用现有 Aibibu 企业账号登录。", e.origin+"/enterprise/login")
+			c.Abort()
+			return
+		}
+		if signedIn {
+			if c.Request.Method == http.MethodGet && c.GetHeader("Sec-Fetch-Dest") == "document" && c.GetHeader("Upgrade") == "" && allowedEnterpriseUIPath(path) {
+				c.Redirect(http.StatusFound, "/enterprise/app?path="+url.QueryEscape(path))
+				c.Abort()
+			}
+			return
+		}
+		// Static files carry no credentials. The SPA's private data endpoints remain gated.
+		if c.Request.Method == "GET" && (strings.HasPrefix(path, "/assets/") || strings.HasPrefix(path, "/static/") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".svg") || path == "/favicon.ico") {
+			return
+		}
+		if c.Request.Method == "GET" && strings.Contains(c.GetHeader("Accept"), "text/html") {
+			c.Redirect(302, "/")
+		} else {
+			c.Status(401)
+		}
+		c.Abort()
+	}
+}
+func EnterpriseRoutes(r *gin.Engine) {
+	if Enterprise == nil {
+		return
+	}
+	r.GET("/enterprise/login", enterpriseStart)
+	r.GET("/enterprise/callback", enterpriseCallback)
+	r.GET("/enterprise/complete", enterpriseComplete)
+	r.GET("/enterprise/app", enterpriseApp)
+	r.GET("/enterprise/ready", func(c *gin.Context) {
+		c.Header("Cache-Control", "no-store")
+		s, ok := Enterprise.session(c)
+		if !ok || !nativeSessionMatches(s.nativeSessionID, s.subject, time.Now()) {
+			c.Status(401)
+			return
+		}
+		c.Status(204)
+	})
+}
+
+var enterpriseDeviceID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
+
+// Only observed native UI routes may be opened by the enterprise wrapper. In
+// particular, the admin API prefix and arbitrary URL/query values are denied.
+func allowedEnterpriseUIPath(path string) bool {
+	switch path {
+	case "/devices", "/apps", "/credentials", "/actions", "/appium-grid", "/admin":
+		return true
+	}
+	const control = "/devices/control/"
+	return strings.HasPrefix(path, control) && enterpriseDeviceID.MatchString(strings.TrimPrefix(path, control))
+}
+
+// The official UI stays unchanged inside a same-origin frame. Its logout may
+// navigate only within that SPA, so this outer page observes the independently
+// bound native session and returns the top-level browser to enterprise login.
+func enterpriseApp(c *gin.Context) {
+	e := Enterprise
+	s, ok := e.session(c)
+	if !ok || !nativeSessionMatches(s.nativeSessionID, s.subject, time.Now()) {
+		c.Redirect(http.StatusFound, "/")
+		return
+	}
+	path := c.DefaultQuery("path", "/devices")
+	if !allowedEnterpriseUIPath(path) {
+		c.Status(http.StatusBadRequest)
+		return
+	}
+	nonce := randomOpaque()
+	c.Header("Cache-Control", "no-store")
+	c.Header("Referrer-Policy", "no-referrer")
+	c.Header("Content-Security-Policy", fmt.Sprintf("default-src 'none'; script-src 'nonce-%s'; style-src 'unsafe-inline'; frame-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'", nonce))
+	c.Header("Content-Type", "text/html; charset=utf-8")
+	t := template.Must(template.New("app").Parse(`<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Aibibu 设备中控</title><style>html,body{margin:0;width:100%;height:100%;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style><iframe title="Aibibu 设备中控" src="{{.Path}}" allow="autoplay; clipboard-read; clipboard-write; fullscreen"></iframe><script nonce="{{.Nonce}}">
+let checking=false;async function checkSession(){if(checking)return;checking=true;try{const r=await fetch('/enterprise/ready',{cache:'no-store'});if(r.status===401||r.status===403){location.replace('/');}}catch(e){}finally{checking=false;}}checkSession();setInterval(checkSession,1000);
+</script></html>`))
+	_ = t.Execute(c.Writer, map[string]string{"Nonce": nonce, "Path": path})
+}
+func staffPage(c *gin.Context, title, message, href string) {
+	c.Header("Cache-Control", "no-store")
+	c.Header("Referrer-Policy", "no-referrer")
+	c.Header("X-Frame-Options", "DENY")
+	c.Header("Content-Type", "text/html; charset=utf-8")
+	t := template.Must(template.New("page").Parse(`<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>body{font:16px system-ui;background:#f4f7fb;color:#172339;max-width:440px;margin:14vh auto;padding:24px}main{background:white;padding:32px;border-radius:16px;box-shadow:0 12px 45px #1231}h1{font-size:26px}a{display:inline-block;background:#1769d2;color:white;padding:12px 20px;border-radius:8px;text-decoration:none}</style><main><h1>{{.Title}}</h1><p>{{.Message}}</p><a href="{{.Href}}">使用 Aibibu 企业账号登录</a><p><small><a style="background:none;color:#52647e;padding:0" href="https://gads.aibibu.com/source/">GADS 源码与 Aibibu 修改（AGPL-3.0）</a></small></p></main></html>`))
+	_ = t.Execute(c.Writer, map[string]string{"Title": title, "Message": message, "Href": href})
+}
+func enterpriseStart(c *gin.Context) {
+	e := Enterprise
+	if c.Request.Host != strings.TrimPrefix(e.origin, "https://") {
+		c.Redirect(302, e.origin+"/enterprise/login")
+		return
+	}
+	state, binding, nonce, verifier := randomOpaque(), randomOpaque(), randomOpaque(), oauth2.GenerateVerifier()
+	e.mu.Lock()
+	e.sweepLocked(time.Now())
+	if len(e.flows) >= 2048 {
+		e.mu.Unlock()
+		c.Status(503)
+		return
+	}
+	e.flows[opaqueHash(state)] = loginFlow{opaqueHash(binding), nonce, verifier, time.Now().Add(5 * time.Minute)}
+	e.mu.Unlock()
+	setPrivateCookie(c, flowCookie, binding, 300)
+	c.Header("Cache-Control", "no-store")
+	c.Header("Referrer-Policy", "no-referrer")
+	c.Redirect(302, e.oauth.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)))
+}
+func (e *enterpriseLogin) takeFlow(state, binding string, now time.Time) (loginFlow, bool) {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.sweepLocked(now)
+	key := opaqueHash(state)
+	f, ok := e.flows[key]
+	if !ok || f.binding != opaqueHash(binding) {
+		return loginFlow{}, false
+	}
+	delete(e.flows, key)
+	return f, true
+}
+func (e *enterpriseLogin) allowed(subject string, groups []string) bool {
+	if subject == "" || subject != e.ownerSubject {
+		return false
+	}
+	for _, group := range groups {
+		if group == e.group {
+			return true
+		}
+	}
+	return false
+}
+func enterpriseCallback(c *gin.Context) {
+	e := Enterprise
+	fail := func() {
+		c.Status(403)
+		staffPage(c, "企业登录未完成", "请重新登录，或联系管理员确认设备中控权限。", e.origin+"/enterprise/login")
+	}
+	binding, _ := c.Cookie(flowCookie)
+	flow, ok := e.takeFlow(c.Query("state"), binding, time.Now())
+	setPrivateCookie(c, flowCookie, "", -1)
+	if !ok || c.Query("code") == "" || c.Query("error") != "" {
+		fail()
+		return
+	}
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+	defer cancel()
+	token, err := e.oauth.Exchange(ctx, c.Query("code"), oauth2.VerifierOption(flow.verifier))
+	if err != nil {
+		fail()
+		return
+	}
+	raw, ok := token.Extra("id_token").(string)
+	if !ok {
+		fail()
+		return
+	}
+	id, err := e.verifier.Verify(ctx, raw)
+	if err != nil || id.Nonce != flow.nonce {
+		fail()
+		return
+	}
+	var claims struct {
+		Groups []string `json:"groups"`
+		AZP    string   `json:"azp"`
+	}
+	if id.Claims(&claims) != nil || (len(id.Audience) > 1 && claims.AZP != e.oauth.ClientID) || (claims.AZP != "" && claims.AZP != e.oauth.ClientID) || !e.allowed(id.Subject, claims.Groups) {
+		fail()
+		return
+	}
+	// Stable issuer/subject mapping; emails never grant a role or link an account.
+	if err := ensureEnterpriseUser(db.GlobalMongoStore, id.Subject); err != nil {
+		c.Status(503)
+		return
+	}
+	previousCookie, _ := c.Cookie(enterpriseCookie)
+	session := e.issueSession(id.Subject, previousCookie, time.Now())
+	setPrivateCookie(c, enterpriseCookie, session, 8*3600)
+	c.Header("Cache-Control", "no-store")
+	c.Header("Referrer-Policy", "no-referrer")
+	c.Redirect(303, "/enterprise/complete")
+}
+
+type enterpriseUserStore interface {
+	GetUser(string) (models.User, error)
+	AddOrUpdateUser(models.User) error
+}
+
+// Repeated OIDC login does not replace an existing user's ID, workspace
+// assignments, or other fields. A failed lookup must not become an upsert.
+func ensureEnterpriseUser(store enterpriseUserStore, subject string) error {
+	username := "staff:" + subject
+	user, err := store.GetUser(username)
+	if err == nil {
+		if user.Username != username || user.Role != "admin" {
+			return errors.New("existing enterprise user identity or role differs")
+		}
+		return nil
+	}
+	if !errors.Is(err, mongo.ErrNoDocuments) {
+		return err
+	}
+	return store.AddOrUpdateUser(models.User{Username: username, Role: "admin"})
+}
+
+// Readiness must not keep a native login alive merely by polling it. Check the
+// existing session under its own lock without sliding its inactivity window.
+func nativeSessionMatches(id, subject string, now time.Time) bool {
+	if id == "" || subject == "" {
+		return false
+	}
+	sessionsMu.Lock()
+	defer sessionsMu.Unlock()
+	s, ok := sessions[id]
+	if !ok {
+		return false
+	}
+	if sessionExpired(s, now) {
+		delete(sessions, id)
+		return false
+	}
+	return s.ClientID == "" && s.Username == "staff:"+subject
+}
+
+// Called only after the callback has verified the new ID token and access
+// group. A new browser cookie and fallback ticket are issued on every login;
+// only a live native session for the same verified subject can be inherited.
+func (e *enterpriseLogin) issueSession(subject, previousCookie string, now time.Time) string {
+	cookie, ticket := randomOpaque(), randomOpaque()
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.sweepLocked(now)
+	s := enterpriseSession{subject: subject, ticket: ticket, expires: now.Add(8 * time.Hour), ticketExpires: now.Add(time.Minute)}
+	previousKey := opaqueHash(previousCookie)
+	if previous, ok := e.sessions[previousKey]; ok {
+		if previous.subject == subject && nativeSessionMatches(previous.nativeSessionID, subject, now) {
+			s.nativeSessionID = previous.nativeSessionID
+		}
+		delete(e.sessions, previousKey)
+	}
+	e.sessions[opaqueHash(cookie)] = s
+	return cookie
+}
+
+// Bind only the server's successfully generated and signature-verified token.
+// The official UI remains responsible for its token storage and login form.
+func (e *enterpriseLogin) recordNativeToken(cookie, token string, now time.Time) error {
+	claims, err := ValidateJWT(token, e.origin)
+	if err != nil || claims.Issuer != TokenIssuer || claims.Origin != e.origin {
+		return errors.New("invalid native login token")
+	}
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.sweepLocked(now)
+	key := opaqueHash(cookie)
+	s, ok := e.sessions[key]
+	if !ok || s.ticket != "" || claims.Subject != "staff:"+s.subject || claims.Username != claims.Subject || !nativeSessionMatches(claims.SessionID, s.subject, now) {
+		return errors.New("native login does not match enterprise session")
+	}
+	s.nativeSessionID = claims.SessionID
+	e.sessions[key] = s
+	return nil
+}
+
+func (e *enterpriseLogin) consumeTicket(cookie, ticket string, now time.Time) (string, bool) {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.sweepLocked(now)
+	key := opaqueHash(cookie)
+	s, ok := e.sessions[key]
+	if !ok || ticket == "" || s.ticket != ticket || !now.Before(s.ticketExpires) {
+		return "", false
+	}
+	s.ticket = ""
+	e.sessions[key] = s
+	return "staff:" + s.subject, true
+}
+func EnterpriseCredentials(c *gin.Context, creds AuthCreds) (models.User, error) {
+	if creds.Username != ticketUsername || c.GetHeader("Origin") != Enterprise.origin {
+		return models.User{}, errors.New("enterprise login required")
+	}
+	cookie, _ := c.Cookie(enterpriseCookie)
+	username, ok := Enterprise.consumeTicket(cookie, creds.Password, time.Now())
+	if !ok {
+		return models.User{}, errors.New("invalid login ticket")
+	}
+	return db.GlobalMongoStore.GetUser(username)
+}
+func EnterpriseLogout(c *gin.Context) {
+	if Enterprise == nil {
+		return
+	}
+	cookie, _ := c.Cookie(enterpriseCookie)
+	Enterprise.mu.Lock()
+	delete(Enterprise.sessions, opaqueHash(cookie))
+	Enterprise.mu.Unlock()
+	setPrivateCookie(c, enterpriseCookie, "", -1)
+}
+func enterpriseComplete(c *gin.Context) {
+	e := Enterprise
+	s, ok := e.session(c)
+	if !ok || s.ticket == "" || !time.Now().Before(s.ticketExpires) {
+		c.Redirect(302, "/")
+		return
+	}
+	nonce := randomOpaque()
+	c.Header("Cache-Control", "no-store")
+	c.Header("Referrer-Policy", "no-referrer")
+	c.Header("Content-Security-Policy", fmt.Sprintf("default-src 'none'; script-src 'nonce-%s'; style-src 'unsafe-inline'; frame-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'", nonce))
+	c.Header("Content-Type", "text/html; charset=utf-8")
+	t := template.Must(template.New("complete").Parse(`<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Aibibu 设备中控</title><style>body{font:16px system-ui;padding:32px}iframe{width:100%;height:75vh;border:0}</style><p id="status">企业账号已验证，正在进入设备中控…</p><iframe id="native" title="设备中控登录" src="/?native_login=1"></iframe><script nonce="{{.Nonce}}">
+const ticket={{.Ticket}};let submitted=false;const frame=document.getElementById('native');const started=Date.now();
+const timer=setInterval(async()=>{try{const d=frame.contentDocument;if(!d)return;const pass=d.querySelector('input[type="password"]');const user=d.querySelector('input[placeholder="your.username"]')||d.querySelector('input[type="text"]');const button=[...d.querySelectorAll('button')].find(b=>b.textContent.trim().toLowerCase()==='log in');if(!submitted&&user&&pass&&button){const set=Object.getOwnPropertyDescriptor(frame.contentWindow.HTMLInputElement.prototype,'value').set;set.call(user,'aibibu-oidc-ticket');user.dispatchEvent(new frame.contentWindow.Event('input',{bubbles:true}));set.call(pass,ticket);pass.dispatchEvent(new frame.contentWindow.Event('input',{bubbles:true}));submitted=true;button.click();}if(frame.contentWindow.location.pathname==='/devices'){const r=await fetch('/enterprise/ready');if(r.ok){clearInterval(timer);location.replace('/enterprise/app');}}if(Date.now()-started>30000){clearInterval(timer);document.getElementById('status').textContent='进入中控失败，请重新企业登录。';}}catch(e){clearInterval(timer);document.getElementById('status').textContent='进入中控失败，请重新企业登录。';}},250);
+</script></html>`))
+	_ = t.Execute(c.Writer, map[string]string{"Nonce": nonce, "Ticket": s.ticket})
+}
diff --git a/hub/auth/enterprise_test.go b/hub/auth/enterprise_test.go
new file mode 100644
index 0000000..17161ed
--- /dev/null
+++ b/hub/auth/enterprise_test.go
@@ -0,0 +1,509 @@
+package auth
+
+import (
+	"GADS/common/models"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"go.mongodb.org/mongo-driver/mongo"
+)
+
+func testEnterprise() *enterpriseLogin {
+	return &enterpriseLogin{origin: "https://devices.example.com", ownerSubject: "owner-sub", group: "devices-owner", flows: map[string]loginFlow{}, sessions: map[string]enterpriseSession{}}
+}
+
+type enterpriseUsersStub struct {
+	user     models.User
+	readErr  error
+	writeErr error
+	written  []models.User
+}
+
+func (s *enterpriseUsersStub) GetUser(_ string) (models.User, error) {
+	return s.user, s.readErr
+}
+func (s *enterpriseUsersStub) AddOrUpdateUser(user models.User) error {
+	s.written = append(s.written, user)
+	return s.writeErr
+}
+
+func TestEnterpriseUserRepeatedLoginPreservesAssignedFields(t *testing.T) {
+	store := &enterpriseUsersStub{user: models.User{Username: "staff:owner-sub", Role: "admin", ID: "existing-id", WorkspaceIDs: []string{"assigned-workspace"}}}
+	if err := ensureEnterpriseUser(store, "owner-sub"); err != nil {
+		t.Fatal(err)
+	}
+	if len(store.written) != 0 || store.user.ID != "existing-id" || len(store.user.WorkspaceIDs) != 1 || store.user.WorkspaceIDs[0] != "assigned-workspace" {
+		t.Fatal("repeated enterprise login rewrites existing user fields")
+	}
+}
+
+func TestEnterpriseUserCreationOnlyAfterConfirmedMissingUser(t *testing.T) {
+	store := &enterpriseUsersStub{readErr: mongo.ErrNoDocuments}
+	if err := ensureEnterpriseUser(store, "owner-sub"); err != nil || len(store.written) != 1 {
+		t.Fatal("confirmed missing enterprise user was not created")
+	}
+	u := store.written[0]
+	if u.Username != "staff:owner-sub" || u.Role != "admin" || u.Password != "" || u.ID != "" || len(u.WorkspaceIDs) != 0 {
+		t.Fatal("new enterprise user inherited unrelated fields or credentials")
+	}
+	readErr := errors.New("database unavailable")
+	store = &enterpriseUsersStub{readErr: readErr}
+	if !errors.Is(ensureEnterpriseUser(store, "owner-sub"), readErr) || len(store.written) != 0 {
+		t.Fatal("failed database lookup must not overwrite or create a user")
+	}
+	writeErr := errors.New("write unavailable")
+	store = &enterpriseUsersStub{readErr: mongo.ErrNoDocuments, writeErr: writeErr}
+	if !errors.Is(ensureEnterpriseUser(store, "owner-sub"), writeErr) {
+		t.Fatal("failed user creation was ignored")
+	}
+	for _, user := range []models.User{{Username: "staff:other-sub", Role: "admin"}, {Username: "staff:owner-sub", Role: "user"}} {
+		store = &enterpriseUsersStub{user: user}
+		if ensureEnterpriseUser(store, "owner-sub") == nil || len(store.written) != 0 {
+			t.Fatal("existing user identity or role conflict must fail without a rewrite")
+		}
+	}
+}
+
+func withEnterpriseNativeAuth(t *testing.T) {
+	t.Helper()
+	withEmptySessions(t)
+	withTestLifetimes(t, time.Hour, 8*time.Hour)
+	previousCache := secretCache
+	secretCache = setupTestSecretCache()
+	t.Cleanup(func() { secretCache = previousCache })
+}
+
+func recordEnterpriseLogin(t *testing.T, e *enterpriseLogin, cookie, subject string) string {
+	t.Helper()
+	token, err := GenerateJWT("staff:"+subject, "admin", "tenant", []string{"user", "admin"}, e.origin)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := e.recordNativeToken(cookie, token, time.Now()); err != nil {
+		t.Fatal(err)
+	}
+	claims, err := ValidateJWT(token, e.origin)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return claims.SessionID
+}
+
+func TestEnterpriseNativeTokenMustMatchBoundBrowserAndSubject(t *testing.T) {
+	withEnterpriseNativeAuth(t)
+	e := testEnterprise()
+	now := time.Now()
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: now.Add(time.Hour)}
+	token, err := GenerateJWT("staff:owner-sub", "admin", "tenant", []string{"admin"}, e.origin)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if e.recordNativeToken("foreign-browser", token, now) == nil {
+		t.Fatal("foreign browser accepted")
+	}
+	if e.recordNativeToken("browser", token+"tampered", now) == nil {
+		t.Fatal("invalid token signature accepted")
+	}
+	other, err := GenerateJWT("staff:other-sub", "admin", "tenant", []string{"admin"}, e.origin)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if e.recordNativeToken("browser", other, now) == nil {
+		t.Fatal("different subject accepted")
+	}
+	machine, err := GenerateClientCredentialsJWT("machine", "staff:owner-sub", "admin", "tenant", []string{"admin"}, e.origin)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if e.recordNativeToken("browser", machine, now) == nil {
+		t.Fatal("machine session accepted as a native user login")
+	}
+	if err := e.recordNativeToken("browser", token, now); err != nil {
+		t.Fatal("valid native login rejected:", err)
+	}
+	if !nativeSessionMatches(e.sessions[opaqueHash("browser")].nativeSessionID, "owner-sub", now) {
+		t.Fatal("native session was not bound")
+	}
+}
+
+func TestEnterpriseRepeatLoginKeepsLiveSessionAndFallbackTicket(t *testing.T) {
+	withEnterpriseNativeAuth(t)
+	e := testEnterprise()
+	now := time.Now()
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: now.Add(time.Hour)}
+	nativeID := recordEnterpriseLogin(t, e, "browser", "owner-sub")
+	newCookie := e.issueSession("owner-sub", "browser", now)
+	s := e.sessions[opaqueHash(newCookie)]
+	if s.nativeSessionID != nativeID || s.ticket == "" {
+		t.Fatal("repeat login must retain its live native session and a fresh fallback ticket")
+	}
+	if _, ok := e.sessions[opaqueHash("browser")]; ok {
+		t.Fatal("old browser cookie was not rotated")
+	}
+	if _, ok := e.consumeTicket("foreign-browser", s.ticket, now); ok {
+		t.Fatal("fallback ticket accepted from another browser")
+	}
+	if _, ok := e.consumeTicket(newCookie, s.ticket, now); !ok {
+		t.Fatal("fallback ticket must work if the UI lost its cached native token")
+	}
+	if _, ok := e.consumeTicket(newCookie, s.ticket, now); ok {
+		t.Fatal("fallback ticket replay accepted")
+	}
+	replacementID := recordEnterpriseLogin(t, e, newCookie, "owner-sub")
+	if replacementID == nativeID || e.sessions[opaqueHash(newCookie)].nativeSessionID != replacementID {
+		t.Fatal("fallback login did not replace the recorded native session")
+	}
+}
+
+func TestEnterpriseRepeatLoginRejectsUnavailableNativeSessions(t *testing.T) {
+	for _, tc := range []string{"logout", "idle-expired", "absolute-expired", "different-subject", "different-native-user", "foreign-cookie", "enterprise-expired"} {
+		t.Run(tc, func(t *testing.T) {
+			withEnterpriseNativeAuth(t)
+			e := testEnterprise()
+			now := time.Now()
+			e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: now.Add(time.Hour)}
+			id := recordEnterpriseLogin(t, e, "browser", "owner-sub")
+			subject, cookie := "owner-sub", "browser"
+			switch tc {
+			case "logout":
+				DeleteSession(id)
+			case "idle-expired":
+				ageSession(t, id, 2*time.Hour, time.Hour+time.Minute)
+			case "absolute-expired":
+				ageSession(t, id, 9*time.Hour, time.Minute)
+			case "different-subject":
+				subject = "other-sub"
+			case "different-native-user":
+				sessionsMu.Lock()
+				sessions[id].Username = "staff:other-sub"
+				sessionsMu.Unlock()
+			case "foreign-cookie":
+				cookie = "foreign-browser"
+			case "enterprise-expired":
+				s := e.sessions[opaqueHash(cookie)]
+				s.expires = now
+				e.sessions[opaqueHash(cookie)] = s
+			}
+			newCookie := e.issueSession(subject, cookie, time.Now())
+			s := e.sessions[opaqueHash(newCookie)]
+			if s.nativeSessionID != "" || s.ticket == "" {
+				t.Fatal("unavailable or mismatched session reused, or fresh login ticket missing")
+			}
+		})
+	}
+}
+
+func TestEnterpriseReadyRequiresLiveSameIdentitySessionWithoutTouch(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	withEnterpriseNativeAuth(t)
+	previous := Enterprise
+	e := testEnterprise()
+	Enterprise = e
+	t.Cleanup(func() { Enterprise = previous })
+	now := time.Now()
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: now.Add(time.Hour)}
+	r := gin.New()
+	EnterpriseRoutes(r)
+	ready := func(cookie string) int {
+		w := httptest.NewRecorder()
+		req := httptest.NewRequest("GET", "/enterprise/ready", nil)
+		req.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: cookie})
+		r.ServeHTTP(w, req)
+		return w.Code
+	}
+	if ready("browser") != 401 {
+		t.Fatal("consumed ticket alone is insufficient before native JWT creation succeeds")
+	}
+	id := recordEnterpriseLogin(t, e, "browser", "owner-sub")
+	newCookie := e.issueSession("owner-sub", "browser", time.Now())
+	if e.sessions[opaqueHash(newCookie)].ticket == "" || ready(newCookie) != 204 {
+		t.Fatal("repeat SSO login with a live native session should be ready without another form submission")
+	}
+	sessionsMu.Lock()
+	lastUsed := sessions[id].LastUsed
+	sessionsMu.Unlock()
+	if ready(newCookie) != 204 || ready("foreign-browser") != 401 {
+		t.Fatal("incorrect ready result for a valid or foreign browser")
+	}
+	sessionsMu.Lock()
+	unchanged := sessions[id].LastUsed.Equal(lastUsed)
+	sessionsMu.Unlock()
+	if !unchanged {
+		t.Fatal("readiness polling extends the native inactivity timeout")
+	}
+	ageSession(t, id, 2*time.Hour, time.Hour+time.Minute)
+	if ready(newCookie) != 401 {
+		t.Fatal("idle-expired native session remains ready")
+	}
+	// A new fallback login restores readiness, but logout revokes it immediately.
+	s := e.sessions[opaqueHash(newCookie)]
+	if _, ok := e.consumeTicket(newCookie, s.ticket, time.Now()); !ok {
+		t.Fatal("fresh fallback login ticket failed")
+	}
+	id = recordEnterpriseLogin(t, e, newCookie, "owner-sub")
+	DeleteSession(id)
+	if ready(newCookie) != 401 {
+		t.Fatal("logged-out native session remains ready")
+	}
+}
+func TestEnterpriseFlowBrowserBindingAndReplay(t *testing.T) {
+	e := testEnterprise()
+	now := time.Now()
+	e.flows[opaqueHash("state")] = loginFlow{binding: opaqueHash("browser"), expires: now.Add(time.Minute)}
+	if _, ok := e.takeFlow("state", "foreign-browser", now); ok {
+		t.Fatal("foreign browser accepted")
+	}
+	if _, ok := e.takeFlow("state", "browser", now); !ok {
+		t.Fatal("valid flow rejected")
+	}
+	if _, ok := e.takeFlow("state", "browser", now); ok {
+		t.Fatal("flow replay accepted")
+	}
+	e.flows[opaqueHash("old")] = loginFlow{binding: opaqueHash("browser"), expires: now}
+	if _, ok := e.takeFlow("old", "browser", now); ok {
+		t.Fatal("expired flow accepted")
+	}
+}
+func TestEnterpriseTicketExpiryBindingAndReplay(t *testing.T) {
+	e := testEnterprise()
+	now := time.Now()
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", ticket: "ticket", ticketExpires: now.Add(time.Minute), expires: now.Add(time.Hour)}
+	if _, ok := e.consumeTicket("foreign", "ticket", now); ok {
+		t.Fatal("foreign browser accepted")
+	}
+	if _, ok := e.consumeTicket("browser", "wrong-ticket", now); ok {
+		t.Fatal("wrong ticket accepted")
+	}
+	if user, ok := e.consumeTicket("browser", "ticket", now); !ok || user != "staff:owner-sub" {
+		t.Fatal("valid ticket failed")
+	}
+	if _, ok := e.consumeTicket("browser", "ticket", now); ok {
+		t.Fatal("ticket replay accepted")
+	}
+	e.sessions[opaqueHash("expired")] = enterpriseSession{ticket: "old", ticketExpires: now, expires: now.Add(time.Hour)}
+	if _, ok := e.consumeTicket("expired", "old", now); ok {
+		t.Fatal("expired ticket accepted")
+	}
+}
+func TestEnterpriseOwnerRequiresSubjectAndGroup(t *testing.T) {
+	e := testEnterprise()
+	for _, tt := range []struct {
+		sub     string
+		groups  []string
+		allowed bool
+	}{
+		{"owner-sub", []string{"devices-owner"}, true},
+		{"other-user", []string{"devices-owner"}, false},
+		{"owner-sub", nil, false},
+		{"", []string{"devices-owner"}, false},
+	} {
+		if e.allowed(tt.sub, tt.groups) != tt.allowed {
+			t.Fatal("incorrect authorization")
+		}
+	}
+}
+func TestEnterpriseGateAndNativePasswordDisabled(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	previous := Enterprise
+	Enterprise = testEnterprise()
+	defer func() { Enterprise = previous }()
+	r := gin.New()
+	r.Use(EnterpriseGate())
+	r.GET("/available-devices", func(c *gin.Context) { c.Status(200) })
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("GET", "/available-devices?workspaceId=private", nil)
+	r.ServeHTTP(w, req)
+	if w.Code != 401 {
+		t.Fatal("private SSE exposed")
+	}
+	c, _ := gin.CreateTestContext(httptest.NewRecorder())
+	c.Request = httptest.NewRequest("POST", "/authenticate", nil)
+	c.Request.Header.Set("Origin", Enterprise.origin)
+	if _, err := EnterpriseCredentials(c, AuthCreds{Username: "admin", Password: "password"}); err == nil {
+		t.Fatal("native password accepted")
+	}
+	r.NoRoute(func(c *gin.Context) { c.Status(404) })
+	w = httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
+	if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Aibibu 企业账号") {
+		t.Fatal("missing enterprise entry")
+	}
+	// An expired native JWT must be able to reach the sign-in landing page even
+	// while the independent, longer enterprise cookie is still valid.
+	Enterprise.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: time.Now().Add(time.Hour)}
+	w = httptest.NewRecorder()
+	req = httptest.NewRequest("GET", "/", nil)
+	req.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: "browser"})
+	r.ServeHTTP(w, req)
+	if w.Code != http.StatusOK || w.Header().Get("Location") != "" {
+		t.Fatal("native session expiry redirects in a loop")
+	}
+}
+
+func TestEnterpriseUIPathAllowlist(t *testing.T) {
+	for _, path := range []string{"/devices", "/apps", "/credentials", "/actions", "/appium-grid", "/admin", "/devices/control/971336be0806", "/devices/control/1234-abcd_AB"} {
+		if !allowedEnterpriseUIPath(path) {
+			t.Fatal("known native UI path rejected:", path)
+		}
+	}
+	for _, path := range []string{"", "/", "//outside.example", "https://outside.example", "/devices?next=https://outside.example", "/devices#outside", "/devices\\outside", "/admin/users", "/device/phone/stream", "/devices/control/", "/devices/control/../admin", "/devices/control/%2foutside", "/devices/control/phone?token=x", "/devices/control/phone#x", "/devices/control/phone\\x", "/devices/control/phone/x", "/devices/control/" + strings.Repeat("x", 129)} {
+		if allowedEnterpriseUIPath(path) {
+			t.Fatal("non-UI or unsafe wrapper path accepted:", path)
+		}
+	}
+}
+
+func TestEnterpriseAppRequiresLiveNativeSession(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, state := range []string{"valid", "anonymous", "logout", "idle-expired", "absolute-expired", "enterprise-expired"} {
+		t.Run(state, func(t *testing.T) {
+			withEnterpriseNativeAuth(t)
+			previous := Enterprise
+			e := testEnterprise()
+			Enterprise = e
+			t.Cleanup(func() { Enterprise = previous })
+			e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: time.Now().Add(time.Hour)}
+			id := recordEnterpriseLogin(t, e, "browser", "owner-sub")
+			switch state {
+			case "logout":
+				c, _ := gin.CreateTestContext(httptest.NewRecorder())
+				c.Request = httptest.NewRequest(http.MethodPost, "/logout", nil)
+				c.Request.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: "browser"})
+				EnterpriseLogout(c)
+				DeleteSession(id)
+			case "idle-expired":
+				ageSession(t, id, 2*time.Hour, time.Hour+time.Minute)
+			case "absolute-expired":
+				ageSession(t, id, 9*time.Hour, time.Minute)
+			case "enterprise-expired":
+				s := e.sessions[opaqueHash("browser")]
+				s.expires = time.Now().Add(-time.Second)
+				e.sessions[opaqueHash("browser")] = s
+			}
+			r := gin.New()
+			r.Use(EnterpriseGate())
+			EnterpriseRoutes(r)
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(http.MethodGet, "/enterprise/app", nil)
+			if state != "anonymous" {
+				req.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: "browser"})
+			}
+			r.ServeHTTP(w, req)
+			if state == "valid" {
+				if w.Code != 200 || !strings.Contains(w.Body.String(), `src="/devices"`) || !strings.Contains(w.Body.String(), "fetch('/enterprise/ready',{cache:'no-store'})") || !strings.Contains(w.Body.String(), "location.replace('/')") || w.Header().Get("Cache-Control") != "no-store" {
+					t.Fatal("valid enterprise wrapper is missing native UI or logout/expiry observation")
+				}
+			} else if w.Code != 302 || w.Header().Get("Location") != "/" {
+				t.Fatal("unavailable session must return to the enterprise login entry")
+			}
+		})
+	}
+}
+
+func TestEnterpriseAppRejectsUnsafePaths(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	withEnterpriseNativeAuth(t)
+	previous := Enterprise
+	e := testEnterprise()
+	Enterprise = e
+	t.Cleanup(func() { Enterprise = previous })
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: time.Now().Add(time.Hour)}
+	recordEnterpriseLogin(t, e, "browser", "owner-sub")
+	r := gin.New()
+	EnterpriseRoutes(r)
+	for _, path := range []string{"//outside.example", "https://outside.example", "/devices?redirect=https://outside.example", "/devices\\outside", "/admin/users"} {
+		w := httptest.NewRecorder()
+		req := httptest.NewRequest(http.MethodGet, "/enterprise/app?path="+url.QueryEscape(path), nil)
+		req.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: "browser"})
+		r.ServeHTTP(w, req)
+		if w.Code != 400 || w.Header().Get("Location") != "" {
+			t.Fatal("unsafe app path caused a navigation:", path)
+		}
+	}
+}
+
+func TestEnterpriseGateWrapsOnlyTopLevelKnownUIAndServesNativeFrames(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	previous := Enterprise
+	e := testEnterprise()
+	Enterprise = e
+	t.Cleanup(func() { Enterprise = previous })
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", expires: time.Now().Add(time.Hour)}
+	r := gin.New()
+	r.Use(EnterpriseGate())
+	// This models the existing official-UI serving middleware after the gate.
+	// Root HTML is served before the JSON API handler for that same route.
+	r.Use(func(c *gin.Context) {
+		if c.Request.URL.Path == "/" {
+			c.String(200, "native-index")
+			c.Abort()
+		}
+	})
+	for _, path := range []string{"/devices", "/devices/control/phone", "/apps", "/admin/users", "/device/phone/stream"} {
+		r.GET(path, func(c *gin.Context) { c.String(200, "original:"+c.Request.URL.Path) })
+	}
+	r.POST("/apps", func(c *gin.Context) { c.String(200, "original:/apps") })
+	r.POST("/provider-update", func(c *gin.Context) { c.String(200, "original:/provider-update") })
+	for _, tc := range []struct {
+		method, path, dest, accept, upgrade string
+		anonymous                           bool
+		code                                int
+		body, location                      string
+	}{
+		{method: "GET", path: "/devices", dest: "document", accept: "text/html", code: 302, location: "/enterprise/app?path=" + url.QueryEscape("/devices")},
+		{method: "GET", path: "/devices/control/phone", dest: "document", accept: "text/html", code: 302, location: "/enterprise/app?path=" + url.QueryEscape("/devices/control/phone")},
+		{method: "GET", path: "/apps", dest: "document", accept: "text/html", code: 302, location: "/enterprise/app?path=" + url.QueryEscape("/apps")},
+		{method: "GET", path: "/apps", dest: "iframe", accept: "text/html", code: 200, body: "native-index"},
+		{method: "GET", path: "/devices", dest: "iframe", accept: "text/html", code: 200, body: "native-index"},
+		{method: "GET", path: "/", dest: "iframe", accept: "text/html", code: 200, body: "native-index"},
+		{method: "GET", path: "/apps", accept: "application/json", code: 200, body: "original:/apps"},
+		{method: "GET", path: "/apps", dest: "iframe", accept: "application/json", code: 200, body: "original:/apps"},
+		{method: "GET", path: "/devices", accept: "text/html", code: 200, body: "original:/devices"},
+		{method: "POST", path: "/apps", dest: "document", accept: "text/html", code: 200, body: "original:/apps"},
+		{method: "GET", path: "/admin/users", dest: "document", accept: "text/html", code: 200, body: "original:/admin/users"},
+		{method: "GET", path: "/admin/users", dest: "iframe", accept: "text/html", code: 200, body: "original:/admin/users"},
+		{method: "GET", path: "/device/phone/stream", dest: "document", upgrade: "websocket", code: 200, body: "original:/device/phone/stream"},
+		{method: "POST", path: "/provider-update", dest: "document", anonymous: true, code: 200, body: "original:/provider-update"},
+		{method: "GET", path: "/apps", dest: "iframe", accept: "text/html", anonymous: true, code: 401},
+		{method: "GET", path: "/", dest: "iframe", accept: "text/html", anonymous: true, code: 401},
+	} {
+		t.Run(tc.method+tc.path+tc.dest+tc.accept+tc.upgrade, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(tc.method, tc.path, nil)
+			req.Header.Set("Sec-Fetch-Dest", tc.dest)
+			req.Header.Set("Accept", tc.accept)
+			req.Header.Set("Upgrade", tc.upgrade)
+			if !tc.anonymous {
+				req.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: "browser"})
+			}
+			r.ServeHTTP(w, req)
+			if w.Code != tc.code || w.Header().Get("Location") != tc.location || tc.body != "" && w.Body.String() != tc.body {
+				t.Fatalf("unexpected route result: status=%d location=%q body=%q", w.Code, w.Header().Get("Location"), w.Body.String())
+			}
+		})
+	}
+}
+
+func TestEnterpriseCompleteEntersObservedWrapper(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	previous := Enterprise
+	e := testEnterprise()
+	Enterprise = e
+	t.Cleanup(func() { Enterprise = previous })
+	e.sessions[opaqueHash("browser")] = enterpriseSession{subject: "owner-sub", ticket: "fallback-ticket", expires: time.Now().Add(time.Hour), ticketExpires: time.Now().Add(time.Minute)}
+	r := gin.New()
+	EnterpriseRoutes(r)
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/enterprise/complete", nil)
+	req.AddCookie(&http.Cookie{Name: enterpriseCookie, Value: "browser"})
+	r.ServeHTTP(w, req)
+	if w.Code != 200 || !strings.Contains(w.Body.String(), "location.replace('/enterprise/app')") {
+		t.Fatal("successful native login bridge does not enter the enterprise wrapper")
+	}
+}
diff --git a/hub/hub.go b/hub/hub.go
index 555d975..cda8809 100644
--- a/hub/hub.go
+++ b/hub/hub.go
@@ -39,6 +39,10 @@ import (
 )
 
 func StartHub(flags *pflag.FlagSet, appVersion string, uiFiles fs.FS, resourceFiles embed.FS) {
+	if err := auth.ConfigureEnterprise(); err != nil {
+		slog.Error(err.Error())
+		os.Exit(1)
+	}
 	port, _ := flags.GetString("port")
 	if port == "" {
 		slog.Error("Please provide a port on which the hub instance should run through the --port flag, e.g. --port=10000")
@@ -52,6 +56,10 @@ func StartHub(flags *pflag.FlagSet, appVersion string, uiFiles fs.FS, resourceFi
 	fmt.Printf("Using MongoDB instance on %s. You can change the instance with the --mongo-db flag\n", mongoDB)
 
 	authEnabled, _ := flags.GetBool("auth")
+	if auth.Enterprise != nil && !authEnabled {
+		slog.Error("Enterprise login requires --auth=true")
+		os.Exit(1)
+	}
 	fmt.Printf("Auth enabled: %v. You can enable/disable authentication on hub endpoints with the --auth flag\n", authEnabled)
 
 	// Every request slides a session forward, so the TTL acts as an inactivity
@@ -72,8 +80,12 @@ func StartHub(flags *pflag.FlagSet, appVersion string, uiFiles fs.FS, resourceFi
 	turnUsernameSuffix, _ := flags.GetString("turn-username-suffix")
 	fmt.Printf("TURN username suffix: %s. You can change it with the --turn-username-suffix flag\n", turnUsernameSuffix)
 
-	fmt.Println("Default admin username is `admin`")
-	fmt.Println("Default admin password is `password` unless you've changed it")
+	if auth.Enterprise == nil {
+		fmt.Println("Default admin username is `admin`")
+		fmt.Println("Default admin password is `password` unless you've changed it")
+	} else {
+		fmt.Println("Aibibu enterprise login enabled; native passwords are disabled")
+	}
 
 	filesDir, _ := flags.GetString("files-dir")
 	osTempDir := os.TempDir()
diff --git a/hub/router/handler.go b/hub/router/handler.go
index 92ac22e..870557c 100644
--- a/hub/router/handler.go
+++ b/hub/router/handler.go
@@ -28,7 +28,11 @@ import (
 func HandleRequests(uiFiles fs.FS) *gin.Engine {
 	// Create the router and allow all origins
 	// Allow particular headers as well
-	r := gin.Default()
+	// OIDC callbacks and websocket URLs may contain credentials: never log URLs.
+	r := gin.New()
+	r.Use(gin.Recovery())
+	r.Use(auth.EnterpriseGate())
+	auth.EnterpriseRoutes(r)
 
 	// Add Swagger route
 	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
