Browse Source

Updating OAuth2 login UI, oauth2: replace hardcoded providers with OIDC discovery (#216)

* feat(auth): modernise OAuth/LDAP UI, add custom OAuth provider, and add handler tests

UI:
- Rewrite oauth.html with modern card-based design matching account.html / system_setting
  style: CSS variables, light/dark theme, native toggle switches, provider-aware
  field visibility (GitLab server URL, Custom endpoint rows), callback URL preview
- Rewrite ldap.html with the same design language: hero header, collapsible sections,
  clean form elements, inline connection-test results table with synchronise button
- Both pages integrate with the parent msgbox() toast API and fall back gracefully

Backend (OAuth2):
- Add Config fields: AuthURL, TokenURL, UserInfoURL, UserField, CustomScope
- Add GetProviders() returning [Google, Microsoft, Github, Gitlab, Custom]
- Add OauthHandler.ListProviders HTTP handler → GET /system/auth/oauth/config/providers
- Update ReadConfig / WriteConfig to handle all new fields with proper validation
  (custom provider requires auth/token/userinfo URLs when OAuth is enabled)
- Add custom.go: customUserInfo() fetches from any OIDC-compatible /userinfo endpoint
- Update serviceSelector.go to route all three selector funcs through 'Custom'
  branch and expose GetProviders(); switch getScope default to configurable value

Backend (LDAP):
- Fix ReadConfig to default enabled=false instead of erroring on a fresh DB

Tests:
- oauth2_handler_test.go: 17 tests covering GetProviders, ListProviders, ReadConfig,
  WriteConfig (built-in & Custom providers, round-trips, GitLab server-URL handling,
  scope/endpoint selectors)
- ldap_handler_test.go: 8 tests covering ReadConfig and WriteConfig (defaults,
  validation, round-trip, overwrite)

https://claude.ai/code/session_017STPAsz1DkD6YmdS7iKHpW

* oauth2: replace hardcoded providers with OIDC discovery

- Remove all hardcoded provider files (Google, GitHub, GitLab, Microsoft,
  custom, serviceSelector) — 6 files deleted
- Add discovery.go: FetchOIDCDiscovery pulls endpoints from any provider's
  /.well-known/openid-configuration; getUserInfoFromEndpoint fetches the
  username claim using a configurable field (default: email)
- Rewrite oauth2.go: OauthHandler is now provider-agnostic; config stores
  raw endpoints populated either via OIDC discovery or typed manually;
  exchangeCodeForUsername extracted for independent testability
- Add HandleDiscover endpoint (/system/auth/oauth/config/discover) that
  returns discovered auth/token/userinfo endpoints and suggested
  scopes/claims to the frontend
- Rewrite oauth.html: Issuer URL + Discover button auto-populates fields;
  manual endpoint inputs provided as fallback; dark/light theme support
- Add 27 discovery tests (oauth2_discovery_test.go): URL building, full
  discovery round-trip, HTTP error cases, JSON validation, network failure,
  getUserInfoFromEndpoint with real mock servers (Bearer header, field
  extraction, empty/non-string values)
- Add 32 handler tests (oauth2_handler_test.go): ReadConfig defaults,
  WriteConfig validation, HandleDiscover with mock OIDC provider,
  CheckOAuth, HandleLogin/Authorize guards, exchangeCodeForUsername
  end-to-end with mock token+userinfo servers, buildOAuthConfig behaviour
- All 59 tests pass; all mod/auth/... packages green

https://claude.ai/code/session_017STPAsz1DkD6YmdS7iKHpW

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 3 weeks ago
parent
commit
30dd9f38d4

+ 247 - 0
src/mod/auth/ldap/ldap_handler_test.go

@@ -0,0 +1,247 @@
+package ldap
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	"imuslab.com/arozos/mod/auth/ldap/ldapreader"
+	db "imuslab.com/arozos/mod/database"
+)
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+func newTestDB(t *testing.T) (*db.Database, func()) {
+	t.Helper()
+	dir, err := os.MkdirTemp("", "arozos-ldap-test-*")
+	if err != nil {
+		t.Fatalf("MkdirTemp: %v", err)
+	}
+	database, err := db.NewDatabase(dir+"/test.db", false)
+	if err != nil {
+		os.RemoveAll(dir)
+		t.Fatalf("NewDatabase: %v", err)
+	}
+	return database, func() { os.RemoveAll(dir) }
+}
+
+// minimalLdapHandler returns a handler suitable for testing the config-only
+// endpoints (ReadConfig / WriteConfig). Fields not required by those handlers
+// are left nil.
+func minimalLdapHandler(coredb *db.Database) *ldapHandler {
+	if err := coredb.NewTable("ldap"); err != nil {
+		_ = err // table may already exist
+	}
+	return &ldapHandler{
+		coredb:     coredb,
+		ldapreader: ldapreader.NewLDAPReader("", "", "", ""),
+	}
+}
+
+func postForm(t *testing.T, h http.HandlerFunc, values url.Values) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	h(w, req)
+	return w
+}
+
+func getReq(t *testing.T, h http.HandlerFunc) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	w := httptest.NewRecorder()
+	h(w, req)
+	return w
+}
+
+// ── ReadConfig ────────────────────────────────────────────────────────────────
+
+func TestLdapReadConfig_DefaultsToDisabled(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	w := getReq(t, h.ReadConfig)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("ReadConfig returned %d, want 200", w.Code)
+	}
+
+	var cfg Config
+	if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
+		t.Fatalf("response is not valid JSON: %v\nbody: %s", err, w.Body.String())
+	}
+	if cfg.Enabled {
+		t.Error("ReadConfig: expected Enabled=false for fresh DB, got true")
+	}
+}
+
+func TestLdapReadConfig_ReturnsAllFields(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	// Pre-seed values.
+	coredb.Write("ldap", "FQDN", "ldap.example.com")
+	coredb.Write("ldap", "BaseDN", "cn=users,dc=example,dc=com")
+	coredb.Write("ldap", "BindUsername", "admin")
+
+	w := getReq(t, h.ReadConfig)
+	var cfg Config
+	if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
+		t.Fatalf("ReadConfig JSON parse: %v", err)
+	}
+
+	if cfg.FQDN != "ldap.example.com" {
+		t.Errorf("FQDN: got %q, want %q", cfg.FQDN, "ldap.example.com")
+	}
+	if cfg.BaseDN != "cn=users,dc=example,dc=com" {
+		t.Errorf("BaseDN: got %q, want %q", cfg.BaseDN, "cn=users,dc=example,dc=com")
+	}
+	if cfg.BindUsername != "admin" {
+		t.Errorf("BindUsername: got %q, want %q", cfg.BindUsername, "admin")
+	}
+}
+
+// ── WriteConfig ───────────────────────────────────────────────────────────────
+
+func TestLdapWriteConfig_MissingEnabledField(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	w := postForm(t, h.WriteConfig, url.Values{"fqdn": {"ldap.example.com"}})
+
+	body := w.Body.String()
+	if !strings.Contains(body, "error") {
+		t.Errorf("WriteConfig without 'enabled': expected error, got %q", body)
+	}
+}
+
+func TestLdapWriteConfig_DisabledAllowsEmptyFields(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	// When enabled=false, other required fields are optional.
+	values := url.Values{"enabled": {"false"}}
+	w := postForm(t, h.WriteConfig, values)
+
+	body := w.Body.String()
+	if strings.Contains(body, `"error"`) {
+		t.Errorf("WriteConfig disabled: unexpected error: %q", body)
+	}
+}
+
+func TestLdapWriteConfig_EnabledRequiresFields(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	// enabled=true but missing bind_username, bind_password, fqdn, base_dn.
+	values := url.Values{"enabled": {"true"}}
+	w := postForm(t, h.WriteConfig, values)
+	body := w.Body.String()
+	if !strings.Contains(body, "error") {
+		t.Errorf("WriteConfig enabled without required fields: expected error, got %q", body)
+	}
+}
+
+func TestLdapWriteConfig_RoundTrip(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	writeVals := url.Values{
+		"enabled":       {"false"},
+		"bind_username": {"cn=admin,dc=example,dc=com"},
+		"bind_password": {"s3cr3t"},
+		"fqdn":          {"ldap.example.com"},
+		"base_dn":       {"cn=users,dc=example,dc=com"},
+	}
+	wWrite := postForm(t, h.WriteConfig, writeVals)
+	if strings.Contains(wWrite.Body.String(), `"error"`) {
+		t.Fatalf("WriteConfig returned error: %s", wWrite.Body.String())
+	}
+
+	wRead := getReq(t, h.ReadConfig)
+	var cfg Config
+	if err := json.Unmarshal(wRead.Body.Bytes(), &cfg); err != nil {
+		t.Fatalf("ReadConfig JSON parse: %v", err)
+	}
+
+	checks := []struct{ field, got, want string }{
+		{"BindUsername", cfg.BindUsername, "cn=admin,dc=example,dc=com"},
+		{"BindPassword", cfg.BindPassword, "s3cr3t"},
+		{"FQDN", cfg.FQDN, "ldap.example.com"},
+		{"BaseDN", cfg.BaseDN, "cn=users,dc=example,dc=com"},
+	}
+	for _, c := range checks {
+		if c.got != c.want {
+			t.Errorf("%s: got %q, want %q", c.field, c.got, c.want)
+		}
+	}
+}
+
+func TestLdapWriteConfig_UpdateOverwritesPreviousValues(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	h := minimalLdapHandler(coredb)
+
+	// First write.
+	postForm(t, h.WriteConfig, url.Values{
+		"enabled":       {"false"},
+		"bind_username": {"admin"},
+		"bind_password": {"pass1"},
+		"fqdn":          {"ldap.old.com"},
+		"base_dn":       {"dc=old,dc=com"},
+	})
+
+	// Second write with new values.
+	postForm(t, h.WriteConfig, url.Values{
+		"enabled":       {"false"},
+		"bind_username": {"newadmin"},
+		"bind_password": {"pass2"},
+		"fqdn":          {"ldap.new.com"},
+		"base_dn":       {"dc=new,dc=com"},
+	})
+
+	wRead := getReq(t, h.ReadConfig)
+	var cfg Config
+	json.Unmarshal(wRead.Body.Bytes(), &cfg) //nolint:errcheck
+
+	if cfg.FQDN != "ldap.new.com" {
+		t.Errorf("FQDN after update: got %q, want %q", cfg.FQDN, "ldap.new.com")
+	}
+	if cfg.BindUsername != "newadmin" {
+		t.Errorf("BindUsername after update: got %q, want %q", cfg.BindUsername, "newadmin")
+	}
+}
+
+// ── Config struct ─────────────────────────────────────────────────────────────
+
+func TestLdapConfig_JSONTags(t *testing.T) {
+	cfg := Config{
+		Enabled:      true,
+		BindUsername: "user",
+		BindPassword: "pass",
+		FQDN:         "ldap.example.com",
+		BaseDN:       "dc=example,dc=com",
+	}
+	data, err := json.Marshal(cfg)
+	if err != nil {
+		t.Fatalf("Marshal Config: %v", err)
+	}
+	body := string(data)
+
+	for _, want := range []string{`"enabled"`, `"bind_username"`, `"bind_password"`, `"fqdn"`, `"base_dn"`} {
+		if !strings.Contains(body, want) {
+			t.Errorf("Config JSON missing field %s; body: %s", want, body)
+		}
+	}
+}

+ 2 - 2
src/mod/auth/ldap/web_admin.go

@@ -16,8 +16,8 @@ func (ldap *ldapHandler) ReadConfig(w http.ResponseWriter, r *http.Request) {
 	//basic components
 	//basic components
 	enabled, err := strconv.ParseBool(ldap.readSingleConfig("enabled"))
 	enabled, err := strconv.ParseBool(ldap.readSingleConfig("enabled"))
 	if err != nil {
 	if err != nil {
-		utils.SendTextResponse(w, "Invalid config value [key=enabled].")
-		return
+		// Default to false when the key has never been persisted.
+		enabled = false
 	}
 	}
 	//get the LDAP config from db
 	//get the LDAP config from db
 	BindUsername := ldap.readSingleConfig("BindUsername")
 	BindUsername := ldap.readSingleConfig("BindUsername")

+ 159 - 0
src/mod/auth/oauth2/discovery.go

@@ -0,0 +1,159 @@
+package oauth2
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// OIDCDiscovery is the OpenID Connect Discovery document served at
+// {issuer}/.well-known/openid-configuration (RFC 8414 / OIDC Core §4).
+type OIDCDiscovery struct {
+	Issuer                            string   `json:"issuer"`
+	AuthorizationEndpoint             string   `json:"authorization_endpoint"`
+	TokenEndpoint                     string   `json:"token_endpoint"`
+	UserinfoEndpoint                  string   `json:"userinfo_endpoint"`
+	JwksURI                           string   `json:"jwks_uri"`
+	ScopesSupported                   []string `json:"scopes_supported"`
+	ResponseTypesSupported            []string `json:"response_types_supported"`
+	IDTokenSigningAlgValuesSupported  []string `json:"id_token_signing_alg_values_supported"`
+	SubjectTypesSupported             []string `json:"subject_types_supported"`
+	ClaimsSupported                   []string `json:"claims_supported"`
+	GrantTypesSupported               []string `json:"grant_types_supported"`
+}
+
+// DiscoveryResult is the trimmed view returned to the frontend via HandleDiscover.
+type DiscoveryResult struct {
+	AuthEndpoint     string   `json:"auth_endpoint"`
+	TokenEndpoint    string   `json:"token_endpoint"`
+	UserInfoEndpoint string   `json:"userinfo_endpoint"`
+	ScopesSupported  []string `json:"scopes_supported"`
+	ClaimsSupported  []string `json:"claims_supported"`
+}
+
+// httpClient is the HTTP client used for all outbound OIDC calls.
+// Tests replace this to point requests at mock servers.
+var httpClient = &http.Client{Timeout: 10 * time.Second}
+
+// BuildWellKnownURL returns the standard OIDC discovery URL for issuerURL.
+// It is safe to call with a URL that already ends with the well-known path.
+func BuildWellKnownURL(issuerURL string) string {
+	issuerURL = strings.TrimRight(issuerURL, "/")
+	if strings.HasSuffix(issuerURL, ".well-known/openid-configuration") {
+		return issuerURL
+	}
+	return issuerURL + "/.well-known/openid-configuration"
+}
+
+// FetchOIDCDiscovery retrieves and parses the OIDC discovery document for
+// issuerURL. The URL may or may not include the /.well-known/openid-configuration
+// suffix — both forms are handled transparently.
+func FetchOIDCDiscovery(issuerURL string) (*OIDCDiscovery, error) {
+	if strings.TrimSpace(issuerURL) == "" {
+		return nil, fmt.Errorf("issuer URL must not be empty")
+	}
+
+	wellKnownURL := BuildWellKnownURL(issuerURL)
+
+	req, err := http.NewRequest(http.MethodGet, wellKnownURL, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to build discovery request: %w", err)
+	}
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("OIDC discovery request to %s failed: %w", wellKnownURL, err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("OIDC discovery returned HTTP %d from %s", resp.StatusCode, wellKnownURL)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read OIDC discovery response: %w", err)
+	}
+
+	var doc OIDCDiscovery
+	if err := json.Unmarshal(body, &doc); err != nil {
+		return nil, fmt.Errorf("failed to parse OIDC discovery document from %s: %w", wellKnownURL, err)
+	}
+
+	if doc.AuthorizationEndpoint == "" {
+		return nil, fmt.Errorf("OIDC discovery at %s is missing required field 'authorization_endpoint'", wellKnownURL)
+	}
+	if doc.TokenEndpoint == "" {
+		return nil, fmt.Errorf("OIDC discovery at %s is missing required field 'token_endpoint'", wellKnownURL)
+	}
+
+	return &doc, nil
+}
+
+// getUserInfoFromEndpoint calls the OIDC userinfo endpoint with an access token
+// and returns the value of usernameField from the JSON claims.
+// If usernameField is empty, it defaults to "email".
+func getUserInfoFromEndpoint(accessToken, userinfoURL, usernameField string) (string, error) {
+	if userinfoURL == "" {
+		return "", fmt.Errorf("userinfo endpoint URL is not configured")
+	}
+	if usernameField == "" {
+		usernameField = "email"
+	}
+
+	req, err := http.NewRequest(http.MethodGet, userinfoURL, nil)
+	if err != nil {
+		return "", fmt.Errorf("failed to build userinfo request: %w", err)
+	}
+	req.Header.Set("Authorization", "Bearer "+accessToken)
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("userinfo request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		return "", fmt.Errorf("userinfo endpoint rejected the access token (HTTP 401)")
+	}
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("userinfo endpoint returned HTTP %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("failed to read userinfo response: %w", err)
+	}
+
+	var claims map[string]interface{}
+	if err := json.Unmarshal(body, &claims); err != nil {
+		return "", fmt.Errorf("failed to parse userinfo response: %w", err)
+	}
+
+	val, ok := claims[usernameField]
+	if !ok {
+		return "", fmt.Errorf("userinfo response does not contain field %q (available: %v)", usernameField, claimKeys(claims))
+	}
+	username, ok := val.(string)
+	if !ok {
+		return "", fmt.Errorf("userinfo field %q is not a string (got %T)", usernameField, val)
+	}
+	if username == "" {
+		return "", fmt.Errorf("userinfo field %q is empty", usernameField)
+	}
+	return username, nil
+}
+
+// claimKeys returns the sorted key names of a claims map for error messages.
+func claimKeys(m map[string]interface{}) []string {
+	keys := make([]string, 0, len(m))
+	for k := range m {
+		keys = append(keys, k)
+	}
+	return keys
+}

+ 0 - 86
src/mod/auth/oauth2/github.go

@@ -1,86 +0,0 @@
-package oauth2
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-	"time"
-
-	"golang.org/x/oauth2"
-	"golang.org/x/oauth2/github"
-)
-
-type GithubField struct {
-	Login                   string      `json:"login"`
-	ID                      int         `json:"id"`
-	NodeID                  string      `json:"node_id"`
-	AvatarURL               string      `json:"avatar_url"`
-	GravatarID              string      `json:"gravatar_id"`
-	URL                     string      `json:"url"`
-	HTMLURL                 string      `json:"html_url"`
-	FollowersURL            string      `json:"followers_url"`
-	FollowingURL            string      `json:"following_url"`
-	GistsURL                string      `json:"gists_url"`
-	StarredURL              string      `json:"starred_url"`
-	SubscriptionsURL        string      `json:"subscriptions_url"`
-	OrganizationsURL        string      `json:"organizations_url"`
-	ReposURL                string      `json:"repos_url"`
-	EventsURL               string      `json:"events_url"`
-	ReceivedEventsURL       string      `json:"received_events_url"`
-	Type                    string      `json:"type"`
-	SiteAdmin               bool        `json:"site_admin"`
-	Name                    string      `json:"name"`
-	Company                 string      `json:"company"`
-	Blog                    string      `json:"blog"`
-	Location                string      `json:"location"`
-	Email                   interface{} `json:"email"`
-	Hireable                interface{} `json:"hireable"`
-	Bio                     string      `json:"bio"`
-	TwitterUsername         interface{} `json:"twitter_username"`
-	PublicRepos             int         `json:"public_repos"`
-	PublicGists             int         `json:"public_gists"`
-	Followers               int         `json:"followers"`
-	Following               int         `json:"following"`
-	CreatedAt               time.Time   `json:"created_at"`
-	UpdatedAt               time.Time   `json:"updated_at"`
-	PrivateGists            int         `json:"private_gists"`
-	TotalPrivateRepos       int         `json:"total_private_repos"`
-	OwnedPrivateRepos       int         `json:"owned_private_repos"`
-	DiskUsage               int         `json:"disk_usage"`
-	Collaborators           int         `json:"collaborators"`
-	TwoFactorAuthentication bool        `json:"two_factor_authentication"`
-	Plan                    struct {
-		Name          string `json:"name"`
-		Space         int    `json:"space"`
-		Collaborators int    `json:"collaborators"`
-		PrivateRepos  int    `json:"private_repos"`
-	} `json:"plan"`
-}
-
-func githubScope() []string {
-	return []string{"read:user"}
-}
-
-func githubEndpoint() oauth2.Endpoint {
-	return github.Endpoint
-}
-
-func githubUserInfo(accessToken string) (string, error) {
-	client := &http.Client{}
-	req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
-	if err != nil {
-		return "", err
-	}
-	req.Header.Set("Authorization", "token "+accessToken)
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
-	response, err := client.Do(req)
-	if err != nil {
-		return "", err
-	}
-	defer response.Body.Close()
-	contents, err := io.ReadAll(response.Body)
-	var data GithubField
-	json.Unmarshal([]byte(contents), &data)
-
-	return data.Login + "@github.com", err
-}

+ 0 - 88
src/mod/auth/oauth2/gitlab.go

@@ -1,88 +0,0 @@
-package oauth2
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-	"net/url"
-	"time"
-
-	"golang.org/x/oauth2"
-)
-
-type GitlabField struct {
-	ID                             int           `json:"id"`
-	Name                           string        `json:"name"`
-	Username                       string        `json:"username"`
-	State                          string        `json:"state"`
-	AvatarURL                      string        `json:"avatar_url"`
-	WebURL                         string        `json:"web_url"`
-	CreatedAt                      time.Time     `json:"created_at"`
-	Bio                            string        `json:"bio"`
-	BioHTML                        string        `json:"bio_html"`
-	Location                       interface{}   `json:"location"`
-	PublicEmail                    string        `json:"public_email"`
-	Skype                          string        `json:"skype"`
-	Linkedin                       string        `json:"linkedin"`
-	Twitter                        string        `json:"twitter"`
-	WebsiteURL                     string        `json:"website_url"`
-	Organization                   interface{}   `json:"organization"`
-	JobTitle                       string        `json:"job_title"`
-	Pronouns                       interface{}   `json:"pronouns"`
-	Bot                            bool          `json:"bot"`
-	WorkInformation                interface{}   `json:"work_information"`
-	Followers                      int           `json:"followers"`
-	Following                      int           `json:"following"`
-	LastSignInAt                   time.Time     `json:"last_sign_in_at"`
-	ConfirmedAt                    time.Time     `json:"confirmed_at"`
-	LastActivityOn                 string        `json:"last_activity_on"`
-	Email                          string        `json:"email"`
-	ThemeID                        int           `json:"theme_id"`
-	ColorSchemeID                  int           `json:"color_scheme_id"`
-	ProjectsLimit                  int           `json:"projects_limit"`
-	CurrentSignInAt                time.Time     `json:"current_sign_in_at"`
-	Identities                     []interface{} `json:"identities"`
-	CanCreateGroup                 bool          `json:"can_create_group"`
-	CanCreateProject               bool          `json:"can_create_project"`
-	TwoFactorEnabled               bool          `json:"two_factor_enabled"`
-	External                       bool          `json:"external"`
-	PrivateProfile                 bool          `json:"private_profile"`
-	CommitEmail                    string        `json:"commit_email"`
-	SharedRunnersMinutesLimit      interface{}   `json:"shared_runners_minutes_limit"`
-	ExtraSharedRunnersMinutesLimit interface{}   `json:"extra_shared_runners_minutes_limit"`
-	IsAdmin                        bool          `json:"is_admin"`
-	Note                           interface{}   `json:"note"`
-	UsingLicenseSeat               bool          `json:"using_license_seat"`
-}
-
-func gitlabScope() []string {
-	return []string{"read_user api read_api"}
-}
-
-func gitlabEndpoint(server string) oauth2.Endpoint {
-	Endpoint := oauth2.Endpoint{
-		AuthURL:  server + "/oauth/authorize",
-		TokenURL: server + "/oauth/token",
-	}
-	return Endpoint
-}
-
-func gitlabUserInfo(accessToken string, server string) (string, error) {
-	response, err := http.Get(server + "/api/v4/user?access_token=" + accessToken)
-	if err != nil {
-		return "", err
-	}
-	defer response.Body.Close()
-	contents, err := io.ReadAll(response.Body)
-	if err != nil {
-		return "", err
-	}
-	var data GitlabField
-	json.Unmarshal([]byte(contents), &data)
-
-	serverURL, err := url.Parse(server)
-	if err != nil {
-		return "", err
-	}
-	return data.Username + "@" + serverURL.Hostname(), err
-}

+ 0 - 43
src/mod/auth/oauth2/google.go

@@ -1,43 +0,0 @@
-package oauth2
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-
-	"golang.org/x/oauth2"
-	"golang.org/x/oauth2/google"
-)
-
-type GoogleField struct {
-	ID            string `json:"id"`
-	Email         string `json:"email"`
-	VerifiedEmail bool   `json:"verified_email"`
-	Name          string `json:"name"`
-	GivenName     string `json:"given_name"`
-	FamilyName    string `json:"family_name"`
-	Picture       string `json:"picture"`
-	Locale        string `json:"locale"`
-}
-
-func googleScope() []string {
-	return []string{"https://www.googleapis.com/auth/userinfo.profile",
-		"https://www.googleapis.com/auth/userinfo.email"}
-}
-
-func googleEndpoint() oauth2.Endpoint {
-	return google.Endpoint
-}
-
-func googleUserInfo(accessToken string) (string, error) {
-	response, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + accessToken)
-	if err != nil {
-		return "", err
-	}
-	defer response.Body.Close()
-	contents, err := io.ReadAll(response.Body)
-	var data GoogleField
-	json.Unmarshal([]byte(contents), &data)
-
-	return data.Email, err
-}

+ 0 - 48
src/mod/auth/oauth2/microsoft.go

@@ -1,48 +0,0 @@
-package oauth2
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-
-	"golang.org/x/oauth2"
-)
-
-type MicrosoftField struct {
-	Sub        string `json:"sub"`
-	Name       string `json:"name"`
-	GivenName  string `json:"given_name"`
-	FamilyName string `json:"family_name"`
-	Email      string `json:"email"`
-	Picture    string `json:"picture"`
-}
-
-func microsoftScope() []string {
-	return []string{"user.read openid email profile"}
-}
-
-func microsoftEndpoint() oauth2.Endpoint {
-	return oauth2.Endpoint{
-		AuthURL:  "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize",
-		TokenURL: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
-	}
-}
-
-func microsoftUserInfo(accessToken string) (string, error) {
-	client := &http.Client{}
-	req, err := http.NewRequest("GET", "https://graph.microsoft.com/oidc/userinfo", nil)
-	if err != nil {
-		return "", err
-	}
-	req.Header.Set("Authorization", "Bearer "+accessToken)
-	response, err := client.Do(req)
-	if err != nil {
-		return "", err
-	}
-	defer response.Body.Close()
-	contents, err := io.ReadAll(response.Body)
-	var data MicrosoftField
-	json.Unmarshal([]byte(contents), &data)
-
-	return data.Email, err
-}

+ 252 - 193
src/mod/auth/oauth2/oauth2.go

@@ -3,6 +3,8 @@ package oauth2
 import (
 import (
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
+	"errors"
+	"fmt"
 	"log"
 	"log"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
@@ -17,322 +19,379 @@ import (
 	"imuslab.com/arozos/mod/utils"
 	"imuslab.com/arozos/mod/utils"
 )
 )
 
 
+// OauthHandler manages OAuth2 / OIDC authentication for ArozOS.
+// All provider configuration is discovery-driven: the administrator supplies
+// an issuer URL and the handler fetches endpoints from the standard
+// /.well-known/openid-configuration document.
 type OauthHandler struct {
 type OauthHandler struct {
-	googleOauthConfig *oauth2.Config
-	syncDb            *syncdb.SyncDB
-	ag                *auth.AuthAgent
-	reg               *reg.RegisterHandler
-	coredb            *db.Database
+	syncDb *syncdb.SyncDB
+	ag     *auth.AuthAgent
+	reg    *reg.RegisterHandler
+	coredb *db.Database
 }
 }
 
 
+// Config holds the persisted OAuth2 / OIDC settings.
+// Endpoints are normally auto-populated via OIDC Discovery but can be
+// overridden manually for providers that do not publish a discovery document.
 type Config struct {
 type Config struct {
-	Enabled      bool   `json:"enabled"`
-	AutoRedirect bool   `json:"auto_redirect"`
-	IDP          string `json:"idp"`
-	RedirectURL  string `json:"redirect_url"`
-	ServerURL    string `json:"server_url"`
+	Enabled      bool `json:"enabled"`
+	AutoRedirect bool `json:"auto_redirect"`
+
+	// Provider identity (used to trigger discovery)
+	IssuerURL string `json:"issuer_url"`
+
+	// Application credentials
 	ClientID     string `json:"client_id"`
 	ClientID     string `json:"client_id"`
 	ClientSecret string `json:"client_secret"`
 	ClientSecret string `json:"client_secret"`
+
+	// ArozOS server base URL (used to build the callback URL)
+	RedirectURL string `json:"redirect_url"`
+
+	// Space-separated OAuth2 scopes (default: "openid email profile")
+	Scope string `json:"scope"`
+
+	// JSON field in the userinfo response to use as the ArozOS username
+	// (default: "email")
+	UsernameField string `json:"username_field"`
+
+	// OAuth2 endpoints — populated from OIDC discovery or set manually
+	AuthEndpoint     string `json:"auth_endpoint"`
+	TokenEndpoint    string `json:"token_endpoint"`
+	UserInfoEndpoint string `json:"userinfo_endpoint"`
 }
 }
 
 
-// NewOauthHandler xxx
+// NewOauthHandler creates and initialises the OAuth2 handler.
 func NewOauthHandler(authAgent *auth.AuthAgent, register *reg.RegisterHandler, coreDb *db.Database) *OauthHandler {
 func NewOauthHandler(authAgent *auth.AuthAgent, register *reg.RegisterHandler, coreDb *db.Database) *OauthHandler {
-	err := coreDb.NewTable("oauth")
-	if err != nil {
+	if err := coreDb.NewTable("oauth"); err != nil {
 		log.Println("Failed to create oauth database. Terminating.")
 		log.Println("Failed to create oauth database. Terminating.")
 		panic(err)
 		panic(err)
 	}
 	}
-
-	NewlyCreatedOauthHandler := OauthHandler{
-		googleOauthConfig: &oauth2.Config{
-			RedirectURL:  readSingleConfig("redirecturl", coreDb) + "/system/auth/oauth/authorize",
-			ClientID:     readSingleConfig("clientid", coreDb),
-			ClientSecret: readSingleConfig("clientsecret", coreDb),
-			Scopes:       getScope(coreDb),
-			Endpoint:     getEndpoint(coreDb),
-		},
+	return &OauthHandler{
 		ag:     authAgent,
 		ag:     authAgent,
 		syncDb: syncdb.NewSyncDB(),
 		syncDb: syncdb.NewSyncDB(),
 		reg:    register,
 		reg:    register,
 		coredb: coreDb,
 		coredb: coreDb,
 	}
 	}
+}
+
+// buildOAuthConfig constructs a golang.org/x/oauth2.Config from the stored settings.
+// Returns nil when the configuration is incomplete.
+func (oh *OauthHandler) buildOAuthConfig() *oauth2.Config {
+	authEndpoint := oh.readSingleConfig("authendpoint")
+	tokenEndpoint := oh.readSingleConfig("tokenendpoint")
+	clientID := oh.readSingleConfig("clientid")
+	clientSecret := oh.readSingleConfig("clientsecret")
+	redirectURL := oh.readSingleConfig("redirecturl")
+	scope := oh.readSingleConfig("scope")
+	if scope == "" {
+		scope = "openid email profile"
+	}
+
+	if authEndpoint == "" || tokenEndpoint == "" || clientID == "" {
+		return nil
+	}
 
 
-	return &NewlyCreatedOauthHandler
+	return &oauth2.Config{
+		RedirectURL:  redirectURL + "/system/auth/oauth/authorize",
+		ClientID:     clientID,
+		ClientSecret: clientSecret,
+		Scopes:       strings.Fields(scope),
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  authEndpoint,
+			TokenURL: tokenEndpoint,
+		},
+	}
 }
 }
 
 
-// HandleOauthLogin xxx
+// ── Login / Authorize ─────────────────────────────────────────────────────────
+
+// HandleLogin initiates the OAuth2 / OIDC login flow by redirecting the user
+// to the provider's authorization endpoint.
 func (oh *OauthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
 func (oh *OauthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
-	enabled := oh.readSingleConfig("enabled")
-	if enabled == "" || enabled == "false" {
+	if oh.readSingleConfig("enabled") != "true" {
 		utils.SendTextResponse(w, "OAuth disabled")
 		utils.SendTextResponse(w, "OAuth disabled")
 		return
 		return
 	}
 	}
-	//add cookies
+
+	cfg := oh.buildOAuthConfig()
+	if cfg == nil {
+		utils.SendTextResponse(w, "OAuth is not properly configured (missing endpoints or client ID)")
+		return
+	}
+
 	redirect, err := utils.GetPara(r, "redirect")
 	redirect, err := utils.GetPara(r, "redirect")
-	//store the redirect url to the sync map
-	uuid := ""
+	var uuid string
 	if err != nil {
 	if err != nil {
 		uuid = oh.syncDb.Store("/")
 		uuid = oh.syncDb.Store("/")
 	} else {
 	} else {
 		uuid = oh.syncDb.Store(redirect)
 		uuid = oh.syncDb.Store(redirect)
 	}
 	}
-	//store the key to client
+
 	oh.addCookie(w, "uuid_login", uuid, 30*time.Minute)
 	oh.addCookie(w, "uuid_login", uuid, 30*time.Minute)
-	//handle redirect
-	url := oh.googleOauthConfig.AuthCodeURL(uuid)
-	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+	http.Redirect(w, r, cfg.AuthCodeURL(uuid), http.StatusTemporaryRedirect)
 }
 }
 
 
-// OauthAuthorize xxx
+// HandleAuthorize processes the OAuth2 callback, exchanges the code for a token,
+// fetches the user identity, and logs the user into ArozOS.
 func (oh *OauthHandler) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
 func (oh *OauthHandler) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
-	enabled := oh.readSingleConfig("enabled")
-	if enabled == "" || enabled == "false" {
+	if oh.readSingleConfig("enabled") != "true" {
 		utils.SendTextResponse(w, "OAuth disabled")
 		utils.SendTextResponse(w, "OAuth disabled")
 		return
 		return
 	}
 	}
-	//read the uuid(aka the state parameter)
-	uuid, err := r.Cookie("uuid_login")
+
+	uuidCookie, err := r.Cookie("uuid_login")
 	if err != nil {
 	if err != nil {
 		utils.SendTextResponse(w, "Invalid redirect URI.")
 		utils.SendTextResponse(w, "Invalid redirect URI.")
 		return
 		return
 	}
 	}
 
 
 	state, err := utils.PostPara(r, "state")
 	state, err := utils.PostPara(r, "state")
-	if state != uuid.Value {
-		utils.SendTextResponse(w, "Invalid oauth state.")
-		return
-	}
 	if err != nil {
 	if err != nil {
 		utils.SendTextResponse(w, "Invalid state parameter.")
 		utils.SendTextResponse(w, "Invalid state parameter.")
 		return
 		return
 	}
 	}
-
-	code, err := utils.PostPara(r, "code")
-	if err != nil {
-		utils.SendTextResponse(w, "Invalid state parameter.")
+	if state != uuidCookie.Value {
+		utils.SendTextResponse(w, "Invalid oauth state.")
 		return
 		return
 	}
 	}
 
 
-	//exchange the infromation to get code
-	token, err := oh.googleOauthConfig.Exchange(context.Background(), code)
+	code, err := utils.PostPara(r, "code")
 	if err != nil {
 	if err != nil {
-		utils.SendTextResponse(w, "Code exchange failed.")
+		utils.SendTextResponse(w, "Authorization code missing.")
 		return
 		return
 	}
 	}
 
 
-	//get user info
-	username, err := getUserInfo(token.AccessToken, oh.coredb)
+	username, err := oh.exchangeCodeForUsername(r.Context(), code)
 	if err != nil {
 	if err != nil {
-		oh.ag.Logger.LogAuthByRequestInfo(username, r.RemoteAddr, time.Now().Unix(), false, "web")
-		utils.SendTextResponse(w, "Failed to obtain user info.")
+		oh.ag.Logger.LogAuthByRequestInfo("", r.RemoteAddr, time.Now().Unix(), false, "web")
+		utils.SendTextResponse(w, "Authentication failed: "+err.Error())
 		return
 		return
 	}
 	}
 
 
 	if !oh.ag.UserExists(username) {
 	if !oh.ag.UserExists(username) {
-		//register user if not already exists
-		//if registration is closed, return error message.
-		//also makr the login as fail.
 		if oh.reg.AllowRegistry {
 		if oh.reg.AllowRegistry {
 			oh.ag.Logger.LogAuthByRequestInfo(username, r.RemoteAddr, time.Now().Unix(), false, "web")
 			oh.ag.Logger.LogAuthByRequestInfo(username, r.RemoteAddr, time.Now().Unix(), false, "web")
 			http.Redirect(w, r, "/public/register/register.html?user="+username, http.StatusFound)
 			http.Redirect(w, r, "/public/register/register.html?user="+username, http.StatusFound)
 		} else {
 		} else {
 			oh.ag.Logger.LogAuthByRequestInfo(username, r.RemoteAddr, time.Now().Unix(), false, "web")
 			oh.ag.Logger.LogAuthByRequestInfo(username, r.RemoteAddr, time.Now().Unix(), false, "web")
 			w.Header().Set("Content-Type", "text/html")
 			w.Header().Set("Content-Type", "text/html")
-			w.Write([]byte("You are not allowed to register in this system.&nbsp;<a href=\"/\">Back</a>"))
+			w.Write([]byte(`You are not registered in this system.&nbsp;<a href="/">Back</a>`))
 		}
 		}
+		return
+	}
+
+	log.Println(username + " logged in via OAuth.")
+	oh.ag.LoginUserByRequest(w, r, username, true)
+
+	remoteIP := r.Header.Get("X-FORWARDED-FOR")
+	if remoteIP != "" {
+		parts := strings.Split(remoteIP, ", ")
+		remoteIP = parts[len(parts)-1]
 	} else {
 	} else {
-		log.Println(username + " logged in via OAuth.")
-		oh.ag.LoginUserByRequest(w, r, username, true)
-		//handling the reverse proxy remote IP issue
-		remoteIP := r.Header.Get("X-FORWARDED-FOR")
-		if remoteIP != "" {
-			//grab the last known remote IP from header
-			remoteIPs := strings.Split(remoteIP, ", ")
-			remoteIP = remoteIPs[len(remoteIPs)-1]
-		} else {
-			//if there is no X-FORWARDED-FOR, use default remote IP
-			remoteIP = r.RemoteAddr
-		}
-		oh.ag.Logger.LogAuthByRequestInfo(username, remoteIP, time.Now().Unix(), true, "web")
-		//clear the cooke
-		oh.addCookie(w, "uuid_login", "-invaild-", -1)
-		//read the value from db and delete it from db
-		url := oh.syncDb.Read(uuid.Value)
-		oh.syncDb.Delete(uuid.Value)
-		//redirect to the desired page
-		http.Redirect(w, r, url, http.StatusFound)
+		remoteIP = r.RemoteAddr
 	}
 	}
+	oh.ag.Logger.LogAuthByRequestInfo(username, remoteIP, time.Now().Unix(), true, "web")
+
+	oh.addCookie(w, "uuid_login", "-invalid-", -1)
+	redirectURL := oh.syncDb.Read(uuidCookie.Value)
+	oh.syncDb.Delete(uuidCookie.Value)
+	http.Redirect(w, r, redirectURL, http.StatusFound)
 }
 }
 
 
-// CheckOAuth check if oauth is enabled
-func (oh *OauthHandler) CheckOAuth(w http.ResponseWriter, r *http.Request) {
-	enabledB := false
-	enabled := oh.readSingleConfig("enabled")
-	if enabled == "true" {
-		enabledB = true
+// exchangeCodeForUsername exchanges the OAuth2 authorization code for an access
+// token and then fetches the username from the userinfo endpoint.
+// This function is separated to make it independently testable.
+func (oh *OauthHandler) exchangeCodeForUsername(ctx context.Context, code string) (string, error) {
+	cfg := oh.buildOAuthConfig()
+	if cfg == nil {
+		return "", errors.New("oauth is not properly configured")
 	}
 	}
 
 
-	autoredirectB := false
-	autoredirect := oh.readSingleConfig("autoredirect")
-	if autoredirect == "true" {
-		autoredirectB = true
+	token, err := cfg.Exchange(ctx, code)
+	if err != nil {
+		return "", fmt.Errorf("token exchange failed: %w", err) //nolint:wrapcheck
 	}
 	}
 
 
-	type returnFormat struct {
+	userinfoURL := oh.readSingleConfig("userinfoendpoint")
+	usernameField := oh.readSingleConfig("usernamefield")
+	return getUserInfoFromEndpoint(token.AccessToken, userinfoURL, usernameField)
+}
+
+// CheckOAuth reports whether OAuth is enabled and whether auto-redirect is active.
+// Used by the login page.
+func (oh *OauthHandler) CheckOAuth(w http.ResponseWriter, r *http.Request) {
+	type result struct {
 		Enabled      bool `json:"enabled"`
 		Enabled      bool `json:"enabled"`
 		AutoRedirect bool `json:"auto_redirect"`
 		AutoRedirect bool `json:"auto_redirect"`
 	}
 	}
-	json, err := json.Marshal(returnFormat{Enabled: enabledB, AutoRedirect: autoredirectB})
+	j, err := json.Marshal(result{
+		Enabled:      oh.readSingleConfig("enabled") == "true",
+		AutoRedirect: oh.readSingleConfig("autoredirect") == "true",
+	})
 	if err != nil {
 	if err != nil {
-		utils.SendErrorResponse(w, "Error occurred while marshalling JSON response")
+		utils.SendErrorResponse(w, "Error marshalling response")
+		return
 	}
 	}
-	utils.SendJSONResponse(w, string(json))
+	utils.SendJSONResponse(w, string(j))
 }
 }
 
 
-// https://golangcode.com/add-a-http-cookie/
-func (oh *OauthHandler) addCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
-	expire := time.Now().Add(ttl)
-	cookie := http.Cookie{
-		Name:    name,
-		Value:   value,
-		Expires: expire,
+// ── Configuration ─────────────────────────────────────────────────────────────
+
+// HandleDiscover fetches the OIDC discovery document for the given issuer URL
+// and returns the discovered endpoints to the frontend.
+// Query / POST parameter: issuerurl
+func (oh *OauthHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
+	issuerURL, err := utils.GetPara(r, "issuerurl")
+	if err != nil {
+		issuerURL, err = utils.PostPara(r, "issuerurl")
+		if err != nil {
+			utils.SendErrorResponse(w, "issuerurl parameter is required")
+			return
+		}
+	}
+
+	doc, err := FetchOIDCDiscovery(issuerURL)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	result := DiscoveryResult{
+		AuthEndpoint:     doc.AuthorizationEndpoint,
+		TokenEndpoint:    doc.TokenEndpoint,
+		UserInfoEndpoint: doc.UserinfoEndpoint,
+		ScopesSupported:  doc.ScopesSupported,
+		ClaimsSupported:  doc.ClaimsSupported,
+	}
+
+	j, err := json.Marshal(result)
+	if err != nil {
+		utils.SendErrorResponse(w, "Error marshalling discovery result")
+		return
 	}
 	}
-	http.SetCookie(w, &cookie)
+	utils.SendJSONResponse(w, string(j))
 }
 }
 
 
+// ReadConfig returns the full OAuth2 / OIDC configuration as JSON.
 func (oh *OauthHandler) ReadConfig(w http.ResponseWriter, r *http.Request) {
 func (oh *OauthHandler) ReadConfig(w http.ResponseWriter, r *http.Request) {
 	enabled, err := strconv.ParseBool(oh.readSingleConfig("enabled"))
 	enabled, err := strconv.ParseBool(oh.readSingleConfig("enabled"))
 	if err != nil {
 	if err != nil {
-		utils.SendTextResponse(w, "Invalid config value [key=enabled].")
-		return
+		enabled = false
 	}
 	}
 	autoredirect, err := strconv.ParseBool(oh.readSingleConfig("autoredirect"))
 	autoredirect, err := strconv.ParseBool(oh.readSingleConfig("autoredirect"))
 	if err != nil {
 	if err != nil {
-		utils.SendTextResponse(w, "Invalid config value [key=autoredirect].")
-		return
+		autoredirect = false
 	}
 	}
-	idp := oh.readSingleConfig("idp")
-	redirecturl := oh.readSingleConfig("redirecturl")
-	serverurl := oh.readSingleConfig("serverurl")
-	clientid := oh.readSingleConfig("clientid")
-	clientsecret := oh.readSingleConfig("clientsecret")
-
-	config, err := json.Marshal(Config{
-		Enabled:      enabled,
-		AutoRedirect: autoredirect,
-		IDP:          idp,
-		ServerURL:    serverurl,
-		RedirectURL:  redirecturl,
-		ClientID:     clientid,
-		ClientSecret: clientsecret,
-	})
+
+	cfg := Config{
+		Enabled:          enabled,
+		AutoRedirect:     autoredirect,
+		IssuerURL:        oh.readSingleConfig("issuerurl"),
+		ClientID:         oh.readSingleConfig("clientid"),
+		ClientSecret:     oh.readSingleConfig("clientsecret"),
+		RedirectURL:      oh.readSingleConfig("redirecturl"),
+		Scope:            oh.readSingleConfig("scope"),
+		UsernameField:    oh.readSingleConfig("usernamefield"),
+		AuthEndpoint:     oh.readSingleConfig("authendpoint"),
+		TokenEndpoint:    oh.readSingleConfig("tokenendpoint"),
+		UserInfoEndpoint: oh.readSingleConfig("userinfoendpoint"),
+	}
+
+	j, err := json.Marshal(cfg)
 	if err != nil {
 	if err != nil {
-		empty, err := json.Marshal(Config{})
-		if err != nil {
-			utils.SendErrorResponse(w, "Error while marshalling config")
-			return
-		}
+		empty, _ := json.Marshal(Config{})
 		utils.SendJSONResponse(w, string(empty))
 		utils.SendJSONResponse(w, string(empty))
 		return
 		return
 	}
 	}
-	utils.SendJSONResponse(w, string(config))
+	utils.SendJSONResponse(w, string(j))
 }
 }
 
 
+// WriteConfig persists the OAuth2 / OIDC configuration.
+// All endpoint fields may either come from OIDC discovery (triggered by the
+// frontend) or be provided manually.
 func (oh *OauthHandler) WriteConfig(w http.ResponseWriter, r *http.Request) {
 func (oh *OauthHandler) WriteConfig(w http.ResponseWriter, r *http.Request) {
 	enabled, err := utils.PostPara(r, "enabled")
 	enabled, err := utils.PostPara(r, "enabled")
 	if err != nil {
 	if err != nil {
-		utils.SendErrorResponse(w, "enabled field can't be empty")
+		utils.SendErrorResponse(w, "enabled field is required")
 		return
 		return
 	}
 	}
 	autoredirect, err := utils.PostPara(r, "autoredirect")
 	autoredirect, err := utils.PostPara(r, "autoredirect")
 	if err != nil {
 	if err != nil {
-		utils.SendErrorResponse(w, "enabled field can't be empty")
+		utils.SendErrorResponse(w, "autoredirect field is required")
 		return
 		return
 	}
 	}
 
 
-	showError := true
-	if enabled != "true" {
-		showError = false
-	}
+	requireFields := enabled == "true"
 
 
-	idp, err := utils.PostPara(r, "idp")
-	if err != nil {
-		if showError {
-			utils.SendErrorResponse(w, "idp field can't be empty")
-			return
-		}
-	}
-	redirecturl, err := utils.PostPara(r, "redirecturl")
-	if err != nil {
-		if showError {
-			utils.SendErrorResponse(w, "redirecturl field can't be empty")
-			return
-		}
+	issuerURL, _ := utils.PostPara(r, "issuerurl")
+	clientID, err := utils.PostPara(r, "clientid")
+	if err != nil && requireFields {
+		utils.SendErrorResponse(w, "clientid is required when OAuth is enabled")
+		return
 	}
 	}
-	serverurl, err := utils.PostPara(r, "serverurl")
-	if err != nil {
-		if showError {
-			if idp != "Gitlab" {
-				serverurl = ""
-			} else {
-				utils.SendErrorResponse(w, "serverurl field can't be empty")
-				return
-			}
-		}
+	clientSecret, err := utils.PostPara(r, "clientsecret")
+	if err != nil && requireFields {
+		utils.SendErrorResponse(w, "clientsecret is required when OAuth is enabled")
+		return
 	}
 	}
-	if idp != "Gitlab" {
-		serverurl = ""
+	redirectURL, err := utils.PostPara(r, "redirecturl")
+	if err != nil && requireFields {
+		utils.SendErrorResponse(w, "redirecturl is required when OAuth is enabled")
+		return
 	}
 	}
 
 
-	clientid, err := utils.PostPara(r, "clientid")
-	if err != nil {
-		if showError {
-			utils.SendErrorResponse(w, "clientid field can't be empty")
-			return
-		}
+	scope, _ := utils.PostPara(r, "scope")
+	usernameField, _ := utils.PostPara(r, "usernamefield")
+
+	authEndpoint, err := utils.PostPara(r, "authendpoint")
+	if err != nil && requireFields {
+		utils.SendErrorResponse(w, "authendpoint is required when OAuth is enabled")
+		return
 	}
 	}
-	clientsecret, err := utils.PostPara(r, "clientsecret")
-	if err != nil {
-		if showError {
-			utils.SendErrorResponse(w, "clientsecret field can't be empty")
-			return
-		}
+	tokenEndpoint, err := utils.PostPara(r, "tokenendpoint")
+	if err != nil && requireFields {
+		utils.SendErrorResponse(w, "tokenendpoint is required when OAuth is enabled")
+		return
+	}
+	userinfoEndpoint, err := utils.PostPara(r, "userinfoendpoint")
+	if err != nil && requireFields {
+		utils.SendErrorResponse(w, "userinfoendpoint is required when OAuth is enabled")
+		return
 	}
 	}
 
 
 	oh.coredb.Write("oauth", "enabled", enabled)
 	oh.coredb.Write("oauth", "enabled", enabled)
 	oh.coredb.Write("oauth", "autoredirect", autoredirect)
 	oh.coredb.Write("oauth", "autoredirect", autoredirect)
-	oh.coredb.Write("oauth", "idp", idp)
-	oh.coredb.Write("oauth", "redirecturl", redirecturl)
-	oh.coredb.Write("oauth", "serverurl", serverurl)
-	oh.coredb.Write("oauth", "clientid", clientid)
-	oh.coredb.Write("oauth", "clientsecret", clientsecret)
-
-	//update the information inside the oauth class
-	oh.googleOauthConfig = &oauth2.Config{
-		RedirectURL:  oh.readSingleConfig("redirecturl") + "/system/auth/oauth/authorize",
-		ClientID:     oh.readSingleConfig("clientid"),
-		ClientSecret: oh.readSingleConfig("clientsecret"),
-		Scopes:       getScope(oh.coredb),
-		Endpoint:     getEndpoint(oh.coredb),
-	}
+	oh.coredb.Write("oauth", "issuerurl", issuerURL)
+	oh.coredb.Write("oauth", "clientid", clientID)
+	oh.coredb.Write("oauth", "clientsecret", clientSecret)
+	oh.coredb.Write("oauth", "redirecturl", redirectURL)
+	oh.coredb.Write("oauth", "scope", scope)
+	oh.coredb.Write("oauth", "usernamefield", usernameField)
+	oh.coredb.Write("oauth", "authendpoint", authEndpoint)
+	oh.coredb.Write("oauth", "tokenendpoint", tokenEndpoint)
+	oh.coredb.Write("oauth", "userinfoendpoint", userinfoEndpoint)
 
 
 	utils.SendOK(w)
 	utils.SendOK(w)
 }
 }
 
 
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+func (oh *OauthHandler) addCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
+	http.SetCookie(w, &http.Cookie{
+		Name:    name,
+		Value:   value,
+		Expires: time.Now().Add(ttl),
+	})
+}
+
 func (oh *OauthHandler) readSingleConfig(key string) string {
 func (oh *OauthHandler) readSingleConfig(key string) string {
-	var value string
-	err := oh.coredb.Read("oauth", key, &value)
-	if err != nil {
-		value = ""
-	}
-	return value
+	var v string
+	oh.coredb.Read("oauth", key, &v)
+	return v
 }
 }
 
 
 func readSingleConfig(key string, coredb *db.Database) string {
 func readSingleConfig(key string, coredb *db.Database) string {
-	var value string
-	err := coredb.Read("oauth", key, &value)
-	if err != nil {
-		value = ""
-	}
-	return value
+	var v string
+	coredb.Read("oauth", key, &v)
+	return v
 }
 }

+ 461 - 0
src/mod/auth/oauth2/oauth2_discovery_test.go

@@ -0,0 +1,461 @@
+package oauth2
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+// ── BuildWellKnownURL ─────────────────────────────────────────────────────────
+
+func TestBuildWellKnownURL_PlainIssuer(t *testing.T) {
+	got := BuildWellKnownURL("https://accounts.google.com")
+	want := "https://accounts.google.com/.well-known/openid-configuration"
+	if got != want {
+		t.Errorf("got %q, want %q", got, want)
+	}
+}
+
+func TestBuildWellKnownURL_TrailingSlash(t *testing.T) {
+	got := BuildWellKnownURL("https://accounts.google.com/")
+	want := "https://accounts.google.com/.well-known/openid-configuration"
+	if got != want {
+		t.Errorf("got %q, want %q", got, want)
+	}
+}
+
+func TestBuildWellKnownURL_AlreadyHasPath(t *testing.T) {
+	full := "https://accounts.google.com/.well-known/openid-configuration"
+	got := BuildWellKnownURL(full)
+	if got != full {
+		t.Errorf("got %q, want %q (should not double-append)", got, full)
+	}
+}
+
+func TestBuildWellKnownURL_PathPrefix(t *testing.T) {
+	// Some providers have a path prefix (e.g. Keycloak, Azure)
+	got := BuildWellKnownURL("https://login.microsoftonline.com/tenant123/v2.0")
+	want := "https://login.microsoftonline.com/tenant123/v2.0/.well-known/openid-configuration"
+	if got != want {
+		t.Errorf("got %q, want %q", got, want)
+	}
+}
+
+// ── FetchOIDCDiscovery ────────────────────────────────────────────────────────
+
+// minimalDiscoveryDoc returns a valid OIDC discovery JSON body for a given base URL.
+func minimalDiscoveryDoc(base string) []byte {
+	doc := map[string]interface{}{
+		"issuer":                 base,
+		"authorization_endpoint": base + "/oauth2/authorize",
+		"token_endpoint":         base + "/oauth2/token",
+		"userinfo_endpoint":      base + "/oauth2/userinfo",
+		"jwks_uri":               base + "/oauth2/jwks",
+		"scopes_supported":       []string{"openid", "email", "profile"},
+		"claims_supported":       []string{"sub", "email", "name", "preferred_username"},
+		"response_types_supported": []string{"code"},
+		"grant_types_supported":  []string{"authorization_code"},
+	}
+	b, _ := json.Marshal(doc)
+	return b
+}
+
+func newDiscoveryServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, func()) {
+	t.Helper()
+	srv := httptest.NewServer(handler)
+	return srv, srv.Close
+}
+
+// withMockClient temporarily replaces the package-level httpClient with one
+// that talks to the given server. Restored by the returned cleanup func.
+func withMockClient(srv *httptest.Server) func() {
+	orig := httpClient
+	httpClient = srv.Client()
+	return func() { httpClient = orig }
+}
+
+func TestFetchOIDCDiscovery_Success(t *testing.T) {
+	// Declare srv before the closure so the handler can reference it by the
+	// time it is actually invoked (Go closures capture by reference).
+	var srv *httptest.Server
+	srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(srv.URL))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	doc, err := FetchOIDCDiscovery(srv.URL)
+	if err != nil {
+		t.Fatalf("FetchOIDCDiscovery returned error: %v", err)
+	}
+	if doc.AuthorizationEndpoint == "" {
+		t.Error("AuthorizationEndpoint is empty")
+	}
+	if doc.TokenEndpoint == "" {
+		t.Error("TokenEndpoint is empty")
+	}
+	if doc.UserinfoEndpoint == "" {
+		t.Error("UserinfoEndpoint is empty")
+	}
+	if len(doc.ScopesSupported) == 0 {
+		t.Error("ScopesSupported is empty")
+	}
+}
+
+func TestFetchOIDCDiscovery_AcceptsTrailingSlash(t *testing.T) {
+	var srv *httptest.Server
+	srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(srv.URL))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL + "/")
+	if err != nil {
+		t.Errorf("FetchOIDCDiscovery with trailing slash returned error: %v", err)
+	}
+}
+
+func TestFetchOIDCDiscovery_AcceptsFullWellKnownPath(t *testing.T) {
+	var srv *httptest.Server
+	srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(srv.URL))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL + "/.well-known/openid-configuration")
+	if err != nil {
+		t.Errorf("FetchOIDCDiscovery with full path returned error: %v", err)
+	}
+}
+
+func TestFetchOIDCDiscovery_HTTPError_404(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		http.NotFound(w, r)
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL)
+	if err == nil {
+		t.Fatal("expected error for 404 response, got nil")
+	}
+	if !strings.Contains(err.Error(), "404") {
+		t.Errorf("error should mention HTTP 404; got: %v", err)
+	}
+}
+
+func TestFetchOIDCDiscovery_HTTPError_500(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL)
+	if err == nil {
+		t.Fatal("expected error for 500 response, got nil")
+	}
+}
+
+func TestFetchOIDCDiscovery_InvalidJSON(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte("this is not json {{{"))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL)
+	if err == nil {
+		t.Fatal("expected error for invalid JSON, got nil")
+	}
+	if !strings.Contains(err.Error(), "parse") {
+		t.Errorf("error should mention parse failure; got: %v", err)
+	}
+}
+
+func TestFetchOIDCDiscovery_MissingAuthorizationEndpoint(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		doc := map[string]string{
+			// authorization_endpoint deliberately omitted
+			"token_endpoint":    "https://example.com/token",
+			"userinfo_endpoint": "https://example.com/userinfo",
+		}
+		json.NewEncoder(w).Encode(doc)
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL)
+	if err == nil {
+		t.Fatal("expected error for missing authorization_endpoint, got nil")
+	}
+	if !strings.Contains(err.Error(), "authorization_endpoint") {
+		t.Errorf("error should mention missing field; got: %v", err)
+	}
+}
+
+func TestFetchOIDCDiscovery_MissingTokenEndpoint(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		doc := map[string]string{
+			"authorization_endpoint": "https://example.com/auth",
+			// token_endpoint deliberately omitted
+		}
+		json.NewEncoder(w).Encode(doc)
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := FetchOIDCDiscovery(srv.URL)
+	if err == nil {
+		t.Fatal("expected error for missing token_endpoint, got nil")
+	}
+	if !strings.Contains(err.Error(), "token_endpoint") {
+		t.Errorf("error should mention missing field; got: %v", err)
+	}
+}
+
+func TestFetchOIDCDiscovery_EmptyIssuerURL(t *testing.T) {
+	_, err := FetchOIDCDiscovery("")
+	if err == nil {
+		t.Fatal("expected error for empty issuer URL, got nil")
+	}
+}
+
+func TestFetchOIDCDiscovery_NetworkUnreachable(t *testing.T) {
+	// Use a client with a very short timeout so the test completes quickly.
+	orig := httpClient
+	httpClient = &http.Client{Timeout: 100 * time.Millisecond}
+	defer func() { httpClient = orig }()
+
+	// 192.0.2.1 is TEST-NET-1 (RFC 5737) — guaranteed to be unreachable.
+	_, err := FetchOIDCDiscovery("http://192.0.2.1")
+	if err == nil {
+		t.Fatal("expected error for unreachable host, got nil")
+	}
+}
+
+func TestFetchOIDCDiscovery_SetsAcceptHeader(t *testing.T) {
+	var gotAccept string
+	var srv *httptest.Server
+	srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotAccept = r.Header.Get("Accept")
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(srv.URL))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	FetchOIDCDiscovery(srv.URL) //nolint:errcheck
+
+	if !strings.Contains(gotAccept, "application/json") {
+		t.Errorf("expected Accept: application/json, got %q", gotAccept)
+	}
+}
+
+// ── getUserInfoFromEndpoint ───────────────────────────────────────────────────
+
+func newUserInfoServer(t *testing.T, accessToken string, claims map[string]interface{}) (*httptest.Server, func()) {
+	t.Helper()
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		auth := r.Header.Get("Authorization")
+		if auth != "Bearer "+accessToken {
+			w.WriteHeader(http.StatusUnauthorized)
+			fmt.Fprintf(w, `{"error":"invalid_token","got":%q}`, auth)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(claims)
+	}))
+	return srv, srv.Close
+}
+
+func TestGetUserInfoFromEndpoint_Success_EmailField(t *testing.T) {
+	srv, close := newUserInfoServer(t, "tok-abc", map[string]interface{}{
+		"sub":   "user-001",
+		"email": "alice@example.com",
+		"name":  "Alice",
+	})
+	defer close()
+	defer withMockClient(srv)()
+
+	username, err := getUserInfoFromEndpoint("tok-abc", srv.URL, "email")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if username != "alice@example.com" {
+		t.Errorf("got %q, want %q", username, "alice@example.com")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_Success_PreferredUsername(t *testing.T) {
+	srv, close := newUserInfoServer(t, "tok-xyz", map[string]interface{}{
+		"sub":                "usr123",
+		"preferred_username": "bob",
+		"email":              "bob@corp.com",
+	})
+	defer close()
+	defer withMockClient(srv)()
+
+	username, err := getUserInfoFromEndpoint("tok-xyz", srv.URL, "preferred_username")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if username != "bob" {
+		t.Errorf("got %q, want %q", username, "bob")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_DefaultsToEmailField(t *testing.T) {
+	srv, close := newUserInfoServer(t, "tok-def", map[string]interface{}{
+		"email": "charlie@example.org",
+	})
+	defer close()
+	defer withMockClient(srv)()
+
+	// Pass empty usernameField → should default to "email"
+	username, err := getUserInfoFromEndpoint("tok-def", srv.URL, "")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if username != "charlie@example.org" {
+		t.Errorf("got %q, want %q", username, "charlie@example.org")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_BearerTokenSent(t *testing.T) {
+	var capturedAuth string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		capturedAuth = r.Header.Get("Authorization")
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]string{"email": "d@e.com"})
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	getUserInfoFromEndpoint("my-access-token", srv.URL, "email") //nolint:errcheck
+
+	if capturedAuth != "Bearer my-access-token" {
+		t.Errorf("Authorization header: got %q, want %q", capturedAuth, "Bearer my-access-token")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_InvalidToken(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(`{"error":"invalid_token"}`))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := getUserInfoFromEndpoint("bad-token", srv.URL, "email")
+	if err == nil {
+		t.Fatal("expected error for 401 response, got nil")
+	}
+	if !strings.Contains(err.Error(), "401") {
+		t.Errorf("error should mention 401; got: %v", err)
+	}
+}
+
+func TestGetUserInfoFromEndpoint_ServerError(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := getUserInfoFromEndpoint("tok", srv.URL, "email")
+	if err == nil {
+		t.Fatal("expected error for 500, got nil")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_FieldNotFound(t *testing.T) {
+	srv, close := newUserInfoServer(t, "tok", map[string]interface{}{
+		"sub": "123",
+		// "email" is absent
+	})
+	defer close()
+	defer withMockClient(srv)()
+
+	_, err := getUserInfoFromEndpoint("tok", srv.URL, "email")
+	if err == nil {
+		t.Fatal("expected error for missing field, got nil")
+	}
+	if !strings.Contains(err.Error(), "email") {
+		t.Errorf("error should mention missing field name; got: %v", err)
+	}
+}
+
+func TestGetUserInfoFromEndpoint_FieldNotString(t *testing.T) {
+	srv, close := newUserInfoServer(t, "tok", map[string]interface{}{
+		"email": 12345, // not a string
+	})
+	defer close()
+	defer withMockClient(srv)()
+
+	_, err := getUserInfoFromEndpoint("tok", srv.URL, "email")
+	if err == nil {
+		t.Fatal("expected error for non-string field, got nil")
+	}
+	if !strings.Contains(err.Error(), "not a string") {
+		t.Errorf("error should mention 'not a string'; got: %v", err)
+	}
+}
+
+func TestGetUserInfoFromEndpoint_EmptyFieldValue(t *testing.T) {
+	srv, close := newUserInfoServer(t, "tok", map[string]interface{}{
+		"email": "", // empty string
+	})
+	defer close()
+	defer withMockClient(srv)()
+
+	_, err := getUserInfoFromEndpoint("tok", srv.URL, "email")
+	if err == nil {
+		t.Fatal("expected error for empty field value, got nil")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_EmptyURL(t *testing.T) {
+	_, err := getUserInfoFromEndpoint("tok", "", "email")
+	if err == nil {
+		t.Fatal("expected error for empty URL, got nil")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_InvalidJSON(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte("not-json"))
+	}))
+	defer srv.Close()
+	defer withMockClient(srv)()
+
+	_, err := getUserInfoFromEndpoint("tok", srv.URL, "email")
+	if err == nil {
+		t.Fatal("expected error for invalid JSON userinfo, got nil")
+	}
+}
+
+func TestGetUserInfoFromEndpoint_NetworkFailure(t *testing.T) {
+	orig := httpClient
+	httpClient = &http.Client{Timeout: 50 * time.Millisecond}
+	defer func() { httpClient = orig }()
+
+	_, err := getUserInfoFromEndpoint("tok", "http://192.0.2.1/userinfo", "email")
+	if err == nil {
+		t.Fatal("expected error for unreachable host, got nil")
+	}
+}

+ 766 - 0
src/mod/auth/oauth2/oauth2_handler_test.go

@@ -0,0 +1,766 @@
+package oauth2
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	db "imuslab.com/arozos/mod/database"
+	syncdb "imuslab.com/arozos/mod/auth/oauth2/syncdb"
+)
+
+// ── Test infrastructure ───────────────────────────────────────────────────────
+
+func newTestDB(t *testing.T) (*db.Database, func()) {
+	t.Helper()
+	dir, err := os.MkdirTemp("", "arozos-oauth-test-*")
+	if err != nil {
+		t.Fatalf("MkdirTemp: %v", err)
+	}
+	database, err := db.NewDatabase(dir+"/test.db", false)
+	if err != nil {
+		os.RemoveAll(dir)
+		t.Fatalf("NewDatabase: %v", err)
+	}
+	return database, func() { os.RemoveAll(dir) }
+}
+
+// minimalOauthHandler returns a handler with only a live database; ag and reg
+// are nil because the config/discover handlers under test never touch them.
+func minimalOauthHandler(coredb *db.Database) *OauthHandler {
+	_ = coredb.NewTable("oauth") // ignore "already exists"
+	return &OauthHandler{coredb: coredb}
+}
+
+func postForm(t *testing.T, h http.HandlerFunc, values url.Values) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	h(w, req)
+	return w
+}
+
+func getReq(t *testing.T, h http.HandlerFunc) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	w := httptest.NewRecorder()
+	h(w, req)
+	return w
+}
+
+func getReqWithParams(t *testing.T, h http.HandlerFunc, params url.Values) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, "/?"+params.Encode(), nil)
+	w := httptest.NewRecorder()
+	h(w, req)
+	return w
+}
+
+// ── ReadConfig ────────────────────────────────────────────────────────────────
+
+func TestReadConfig_DefaultsToDisabled(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReq(t, oh.ReadConfig)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("ReadConfig returned %d, want 200", w.Code)
+	}
+	var cfg Config
+	if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
+		t.Fatalf("response is not valid JSON: %v; body: %s", err, w.Body)
+	}
+	if cfg.Enabled {
+		t.Error("expected Enabled=false for fresh DB")
+	}
+}
+
+func TestReadConfig_AllFieldsRoundTrip(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	// Seed values
+	coredb.Write("oauth", "issuerurl", "https://idp.example.com")
+	coredb.Write("oauth", "authendpoint", "https://idp.example.com/auth")
+	coredb.Write("oauth", "tokenendpoint", "https://idp.example.com/token")
+	coredb.Write("oauth", "userinfoendpoint", "https://idp.example.com/userinfo")
+	coredb.Write("oauth", "usernamefield", "preferred_username")
+	coredb.Write("oauth", "scope", "openid email")
+
+	w := getReq(t, oh.ReadConfig)
+	var cfg Config
+	if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
+		t.Fatalf("JSON parse: %v", err)
+	}
+
+	checks := []struct{ f, got, want string }{
+		{"IssuerURL", cfg.IssuerURL, "https://idp.example.com"},
+		{"AuthEndpoint", cfg.AuthEndpoint, "https://idp.example.com/auth"},
+		{"TokenEndpoint", cfg.TokenEndpoint, "https://idp.example.com/token"},
+		{"UserInfoEndpoint", cfg.UserInfoEndpoint, "https://idp.example.com/userinfo"},
+		{"UsernameField", cfg.UsernameField, "preferred_username"},
+		{"Scope", cfg.Scope, "openid email"},
+	}
+	for _, c := range checks {
+		if c.got != c.want {
+			t.Errorf("%s: got %q, want %q", c.f, c.got, c.want)
+		}
+	}
+}
+
+// ── WriteConfig ───────────────────────────────────────────────────────────────
+
+func TestWriteConfig_MissingEnabledField(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := postForm(t, oh.WriteConfig, url.Values{"clientid": {"x"}})
+	if !strings.Contains(w.Body.String(), "error") {
+		t.Errorf("expected error without enabled field, got %q", w.Body)
+	}
+}
+
+func TestWriteConfig_DisabledAllowsEmptyFields(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := postForm(t, oh.WriteConfig, url.Values{
+		"enabled": {"false"}, "autoredirect": {"false"},
+	})
+	if strings.Contains(w.Body.String(), `"error"`) {
+		t.Errorf("unexpected error when disabling: %q", w.Body)
+	}
+}
+
+func TestWriteConfig_EnabledRequiresCredentials(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	// enabled=true but clientid missing
+	w := postForm(t, oh.WriteConfig, url.Values{
+		"enabled":          {"true"},
+		"autoredirect":     {"false"},
+		"clientsecret":     {"s"},
+		"redirecturl":      {"https://aroz.example.com"},
+		"authendpoint":     {"https://idp/auth"},
+		"tokenendpoint":    {"https://idp/token"},
+		"userinfoendpoint": {"https://idp/userinfo"},
+	})
+	if !strings.Contains(w.Body.String(), "error") {
+		t.Errorf("expected error for missing clientid: %q", w.Body)
+	}
+}
+
+func TestWriteConfig_EnabledRequiresEndpoints(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	// enabled=true but endpoints missing
+	w := postForm(t, oh.WriteConfig, url.Values{
+		"enabled":      {"true"},
+		"autoredirect": {"false"},
+		"clientid":     {"id"},
+		"clientsecret": {"s"},
+		"redirecturl":  {"https://aroz.example.com"},
+		// authendpoint / tokenendpoint / userinfoendpoint all missing
+	})
+	if !strings.Contains(w.Body.String(), "error") {
+		t.Errorf("expected error for missing endpoints: %q", w.Body)
+	}
+}
+
+func TestWriteConfig_FullRoundTrip(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	in := url.Values{
+		"enabled":          {"false"},
+		"autoredirect":     {"false"},
+		"issuerurl":        {"https://idp.example.com"},
+		"clientid":         {"client-abc"},
+		"clientsecret":     {"secret-xyz"},
+		"redirecturl":      {"https://aroz.example.com"},
+		"scope":            {"openid email profile"},
+		"usernamefield":    {"preferred_username"},
+		"authendpoint":     {"https://idp.example.com/auth"},
+		"tokenendpoint":    {"https://idp.example.com/token"},
+		"userinfoendpoint": {"https://idp.example.com/userinfo"},
+	}
+	wWrite := postForm(t, oh.WriteConfig, in)
+	if strings.Contains(wWrite.Body.String(), `"error"`) {
+		t.Fatalf("WriteConfig error: %s", wWrite.Body)
+	}
+
+	wRead := getReq(t, oh.ReadConfig)
+	var cfg Config
+	if err := json.Unmarshal(wRead.Body.Bytes(), &cfg); err != nil {
+		t.Fatalf("ReadConfig JSON parse: %v", err)
+	}
+
+	checks := []struct{ f, got, want string }{
+		{"IssuerURL", cfg.IssuerURL, "https://idp.example.com"},
+		{"ClientID", cfg.ClientID, "client-abc"},
+		{"ClientSecret", cfg.ClientSecret, "secret-xyz"},
+		{"RedirectURL", cfg.RedirectURL, "https://aroz.example.com"},
+		{"Scope", cfg.Scope, "openid email profile"},
+		{"UsernameField", cfg.UsernameField, "preferred_username"},
+		{"AuthEndpoint", cfg.AuthEndpoint, "https://idp.example.com/auth"},
+		{"TokenEndpoint", cfg.TokenEndpoint, "https://idp.example.com/token"},
+		{"UserInfoEndpoint", cfg.UserInfoEndpoint, "https://idp.example.com/userinfo"},
+	}
+	for _, c := range checks {
+		if c.got != c.want {
+			t.Errorf("%s: got %q, want %q", c.f, c.got, c.want)
+		}
+	}
+	if cfg.Enabled {
+		t.Error("Enabled: got true, want false")
+	}
+}
+
+func TestWriteConfig_OverwritesPreviousValues(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	postForm(t, oh.WriteConfig, url.Values{
+		"enabled": {"false"}, "autoredirect": {"false"},
+		"clientid": {"old-id"},
+	})
+	postForm(t, oh.WriteConfig, url.Values{
+		"enabled": {"false"}, "autoredirect": {"false"},
+		"clientid": {"new-id"},
+	})
+
+	wRead := getReq(t, oh.ReadConfig)
+	var cfg Config
+	json.Unmarshal(wRead.Body.Bytes(), &cfg) //nolint:errcheck
+	if cfg.ClientID != "new-id" {
+		t.Errorf("ClientID: got %q, want %q", cfg.ClientID, "new-id")
+	}
+}
+
+// ── HandleDiscover ────────────────────────────────────────────────────────────
+
+func TestHandleDiscover_Success(t *testing.T) {
+	// Set up a mock OIDC provider. Declare first so the handler closure can
+	// reference providerSrv.URL by the time it is actually invoked.
+	var providerSrv *httptest.Server
+	providerSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(providerSrv.URL))
+	}))
+	defer providerSrv.Close()
+	defer withMockClient(providerSrv)()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReqWithParams(t, oh.HandleDiscover, url.Values{"issuerurl": {providerSrv.URL}})
+	if w.Code != http.StatusOK {
+		t.Fatalf("HandleDiscover returned %d; body: %s", w.Code, w.Body)
+	}
+
+	var result DiscoveryResult
+	if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
+		t.Fatalf("response is not valid JSON: %v; body: %s", err, w.Body)
+	}
+	if result.AuthEndpoint == "" {
+		t.Error("AuthEndpoint is empty in discovery result")
+	}
+	if result.TokenEndpoint == "" {
+		t.Error("TokenEndpoint is empty in discovery result")
+	}
+	if result.UserInfoEndpoint == "" {
+		t.Error("UserInfoEndpoint is empty in discovery result")
+	}
+	if len(result.ScopesSupported) == 0 {
+		t.Error("ScopesSupported is empty in discovery result")
+	}
+}
+
+func TestHandleDiscover_MissingIssuerURL(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReq(t, oh.HandleDiscover)
+	if w.Code != http.StatusOK {
+		t.Fatalf("unexpected status %d", w.Code)
+	}
+	if !strings.Contains(w.Body.String(), "error") {
+		t.Errorf("expected error for missing issuerurl, got %q", w.Body)
+	}
+}
+
+func TestHandleDiscover_ProviderReturns404(t *testing.T) {
+	providerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		http.NotFound(w, r)
+	}))
+	defer providerSrv.Close()
+	defer withMockClient(providerSrv)()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReqWithParams(t, oh.HandleDiscover, url.Values{"issuerurl": {providerSrv.URL}})
+	if !strings.Contains(w.Body.String(), "error") {
+		t.Errorf("expected error for 404 provider, got %q", w.Body)
+	}
+}
+
+func TestHandleDiscover_ScopesSuggested(t *testing.T) {
+	var providerSrv *httptest.Server
+	providerSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(providerSrv.URL))
+	}))
+	defer providerSrv.Close()
+	defer withMockClient(providerSrv)()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReqWithParams(t, oh.HandleDiscover, url.Values{"issuerurl": {providerSrv.URL}})
+	var result DiscoveryResult
+	json.Unmarshal(w.Body.Bytes(), &result) //nolint:errcheck
+	if len(result.ScopesSupported) == 0 {
+		t.Error("ScopesSupported should not be empty after discovery")
+	}
+}
+
+func TestHandleDiscover_ClaimsReturned(t *testing.T) {
+	var providerSrv *httptest.Server
+	providerSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(minimalDiscoveryDoc(providerSrv.URL))
+	}))
+	defer providerSrv.Close()
+	defer withMockClient(providerSrv)()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReqWithParams(t, oh.HandleDiscover, url.Values{"issuerurl": {providerSrv.URL}})
+	var result DiscoveryResult
+	json.Unmarshal(w.Body.Bytes(), &result) //nolint:errcheck
+	if len(result.ClaimsSupported) == 0 {
+		t.Error("ClaimsSupported should not be empty after discovery")
+	}
+}
+
+// ── CheckOAuth ────────────────────────────────────────────────────────────────
+
+func TestCheckOAuth_DisabledByDefault(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	w := getReq(t, oh.CheckOAuth)
+
+	var result struct {
+		Enabled      bool `json:"enabled"`
+		AutoRedirect bool `json:"auto_redirect"`
+	}
+	if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
+		t.Fatalf("JSON parse: %v", err)
+	}
+	if result.Enabled {
+		t.Error("expected Enabled=false by default")
+	}
+}
+
+func TestCheckOAuth_ReflectsStoredValues(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "enabled", "true")
+	coredb.Write("oauth", "autoredirect", "true")
+
+	w := getReq(t, oh.CheckOAuth)
+	var result struct {
+		Enabled      bool `json:"enabled"`
+		AutoRedirect bool `json:"auto_redirect"`
+	}
+	json.Unmarshal(w.Body.Bytes(), &result) //nolint:errcheck
+
+	if !result.Enabled {
+		t.Error("expected Enabled=true")
+	}
+	if !result.AutoRedirect {
+		t.Error("expected AutoRedirect=true")
+	}
+}
+
+// ── HandleLogin guards ────────────────────────────────────────────────────────
+
+func TestHandleLogin_DisabledReturnsText(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+	// "enabled" not set → disabled
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	w := httptest.NewRecorder()
+	oh.HandleLogin(w, req)
+
+	body := w.Body.String()
+	if !strings.Contains(strings.ToLower(body), "disabled") {
+		t.Errorf("expected 'disabled' in response, got %q", body)
+	}
+}
+
+func TestHandleLogin_MisconfiguredNoEndpoints(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "enabled", "true")
+	// no authendpoint / tokenendpoint / clientid
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	w := httptest.NewRecorder()
+	oh.HandleLogin(w, req)
+
+	body := w.Body.String()
+	if strings.Contains(body, "302") || w.Code == http.StatusTemporaryRedirect {
+		t.Errorf("should not redirect when misconfigured; got code %d, body %q", w.Code, body)
+	}
+}
+
+// ── HandleAuthorize guards ────────────────────────────────────────────────────
+
+func TestHandleAuthorize_DisabledReturnsText(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("state=x&code=y"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	oh.HandleAuthorize(w, req)
+
+	if !strings.Contains(strings.ToLower(w.Body.String()), "disabled") {
+		t.Errorf("expected disabled message, got %q", w.Body)
+	}
+}
+
+func TestHandleAuthorize_MissingCookie(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+	coredb.Write("oauth", "enabled", "true")
+
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("state=x&code=y"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	oh.HandleAuthorize(w, req)
+
+	if !strings.Contains(w.Body.String(), "Invalid redirect URI") {
+		t.Errorf("expected 'Invalid redirect URI', got %q", w.Body)
+	}
+}
+
+func TestHandleAuthorize_StateMismatch(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+	coredb.Write("oauth", "enabled", "true")
+	oh.syncDb = syncdb.NewSyncDB()
+
+	uuid := oh.syncDb.Store("/")
+
+	req := httptest.NewRequest(http.MethodPost, "/",
+		strings.NewReader("state=WRONG_STATE&code=x"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.AddCookie(&http.Cookie{Name: "uuid_login", Value: uuid})
+	w := httptest.NewRecorder()
+	oh.HandleAuthorize(w, req)
+
+	if !strings.Contains(w.Body.String(), "Invalid oauth state") {
+		t.Errorf("expected 'Invalid oauth state', got %q", w.Body)
+	}
+}
+
+// ── exchangeCodeForUsername (connectivity) ────────────────────────────────────
+
+// buildMockOIDCStack creates:
+//   - a mock token endpoint server that accepts any code and returns accessToken
+//   - a mock userinfo server that verifies the Bearer token and returns claims
+//
+// Both servers are plain HTTP so the default transport can reach them.
+// The package-level httpClient is replaced for the userinfo call and is
+// restored by the returned closeFn.
+func buildMockOIDCStack(
+	t *testing.T,
+	accessToken string,
+	claims map[string]interface{},
+) (tokenURL, userinfoURL string, closeFn func()) {
+	t.Helper()
+
+	tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"access_token": accessToken,
+			"token_type":   "Bearer",
+			"expires_in":   3600,
+		})
+	}))
+
+	userinfoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		auth := r.Header.Get("Authorization")
+		if auth != "Bearer "+accessToken {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(claims)
+	}))
+
+	// Both test servers are plain HTTP; a standard http.Client can reach both.
+	// We replace httpClient so getUserInfoFromEndpoint uses the same plain transport.
+	origClient := httpClient
+	httpClient = &http.Client{}
+
+	closeFn = func() {
+		tokenSrv.Close()
+		userinfoSrv.Close()
+		httpClient = origClient
+	}
+	return tokenSrv.URL, userinfoSrv.URL, closeFn
+}
+
+// TestExchangeCodeForUsername_Success runs the token exchange → userinfo fetch
+// pipeline against real mock HTTP servers.
+func TestExchangeCodeForUsername_Success(t *testing.T) {
+	const fakeToken = "exchange-tok-abc123"
+	tokenURL, userinfoURL, closeFn := buildMockOIDCStack(t, fakeToken, map[string]interface{}{
+		"sub":   "uid-999",
+		"email": "testuser@example.com",
+	})
+	defer closeFn()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://example.com/auth") // not called
+	coredb.Write("oauth", "tokenendpoint", tokenURL)
+	coredb.Write("oauth", "userinfoendpoint", userinfoURL)
+	coredb.Write("oauth", "clientid", "test-client")
+	coredb.Write("oauth", "clientsecret", "test-secret")
+	coredb.Write("oauth", "redirecturl", "https://aroz.example.com")
+	coredb.Write("oauth", "usernamefield", "email")
+
+	username, err := oh.exchangeCodeForUsername(context.Background(), "some-auth-code")
+	if err != nil {
+		t.Fatalf("exchangeCodeForUsername returned error: %v", err)
+	}
+	if username != "testuser@example.com" {
+		t.Errorf("username: got %q, want %q", username, "testuser@example.com")
+	}
+}
+
+func TestExchangeCodeForUsername_PreferredUsername(t *testing.T) {
+	const fakeToken = "pref-tok"
+	tokenURL, userinfoURL, closeFn := buildMockOIDCStack(t, fakeToken, map[string]interface{}{
+		"sub":                "uid-123",
+		"preferred_username": "alice",
+		"email":              "alice@corp.example",
+	})
+	defer closeFn()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://x/auth")
+	coredb.Write("oauth", "tokenendpoint", tokenURL)
+	coredb.Write("oauth", "userinfoendpoint", userinfoURL)
+	coredb.Write("oauth", "clientid", "cid")
+	coredb.Write("oauth", "clientsecret", "cs")
+	coredb.Write("oauth", "redirecturl", "https://aroz.example.com")
+	coredb.Write("oauth", "usernamefield", "preferred_username")
+
+	username, err := oh.exchangeCodeForUsername(context.Background(), "code")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if username != "alice" {
+		t.Errorf("username: got %q, want %q", username, "alice")
+	}
+}
+
+func TestExchangeCodeForUsername_TokenEndpointError(t *testing.T) {
+	// Token server that always returns 400.
+	tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusBadRequest)
+		w.Write([]byte(`{"error":"invalid_grant"}`))
+	}))
+	defer tokenSrv.Close()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://x/auth")
+	coredb.Write("oauth", "tokenendpoint", tokenSrv.URL)
+	coredb.Write("oauth", "userinfoendpoint", "https://x/userinfo")
+	coredb.Write("oauth", "clientid", "cid")
+	coredb.Write("oauth", "clientsecret", "cs")
+	coredb.Write("oauth", "redirecturl", "https://aroz.example.com")
+
+	_, err := oh.exchangeCodeForUsername(context.Background(), "bad-code")
+	if err == nil {
+		t.Fatal("expected error from failing token endpoint, got nil")
+	}
+	if !strings.Contains(err.Error(), "token exchange failed") {
+		t.Errorf("expected 'token exchange failed' in error, got: %v", err)
+	}
+}
+
+func TestExchangeCodeForUsername_UserInfoError(t *testing.T) {
+	const fakeToken = "good-tok"
+	// Token server succeeds; userinfo server fails.
+	tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"access_token": fakeToken, "token_type": "Bearer", "expires_in": 3600,
+		})
+	}))
+	defer tokenSrv.Close()
+
+	userinfoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer userinfoSrv.Close()
+
+	// Replace httpClient so getUserInfoFromEndpoint uses the same plain transport.
+	origClient := httpClient
+	httpClient = &http.Client{}
+	defer func() { httpClient = origClient }()
+
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://x/auth")
+	coredb.Write("oauth", "tokenendpoint", tokenSrv.URL)
+	coredb.Write("oauth", "userinfoendpoint", userinfoSrv.URL)
+	coredb.Write("oauth", "clientid", "cid")
+	coredb.Write("oauth", "clientsecret", "cs")
+	coredb.Write("oauth", "redirecturl", "https://aroz.example.com")
+	coredb.Write("oauth", "usernamefield", "email")
+
+	_, err := oh.exchangeCodeForUsername(context.Background(), "code")
+	if err == nil {
+		t.Fatal("expected error from failing userinfo endpoint, got nil")
+	}
+}
+
+func TestExchangeCodeForUsername_MisconfiguredNoEndpoints(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+	// No endpoints configured
+
+	_, err := oh.exchangeCodeForUsername(context.Background(), "code")
+	if err == nil {
+		t.Fatal("expected error for unconfigured handler, got nil")
+	}
+}
+
+// ── buildOAuthConfig ─────────────────────────────────────────────────────────
+
+func TestBuildOAuthConfig_NilWhenMissing(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	if oh.buildOAuthConfig() != nil {
+		t.Error("expected nil config when no endpoints are set")
+	}
+}
+
+func TestBuildOAuthConfig_ScopeDefaults(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://x/auth")
+	coredb.Write("oauth", "tokenendpoint", "https://x/token")
+	coredb.Write("oauth", "clientid", "cid")
+	// scope intentionally not set
+
+	cfg := oh.buildOAuthConfig()
+	if cfg == nil {
+		t.Fatal("buildOAuthConfig returned nil")
+	}
+	if len(cfg.Scopes) == 0 {
+		t.Fatal("Scopes should not be empty when scope is not set (should use default)")
+	}
+	defaultScopes := strings.Join(cfg.Scopes, " ")
+	if !strings.Contains(defaultScopes, "openid") {
+		t.Errorf("default scope should contain 'openid', got: %q", defaultScopes)
+	}
+}
+
+func TestBuildOAuthConfig_ScopeFromDB(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://x/auth")
+	coredb.Write("oauth", "tokenendpoint", "https://x/token")
+	coredb.Write("oauth", "clientid", "cid")
+	coredb.Write("oauth", "scope", "openid email custom-scope")
+
+	cfg := oh.buildOAuthConfig()
+	if cfg == nil {
+		t.Fatal("buildOAuthConfig returned nil")
+	}
+	if len(cfg.Scopes) != 3 {
+		t.Errorf("expected 3 scopes, got %d: %v", len(cfg.Scopes), cfg.Scopes)
+	}
+}
+
+func TestBuildOAuthConfig_CallbackURL(t *testing.T) {
+	coredb, cleanup := newTestDB(t)
+	defer cleanup()
+	oh := minimalOauthHandler(coredb)
+
+	coredb.Write("oauth", "authendpoint", "https://x/auth")
+	coredb.Write("oauth", "tokenendpoint", "https://x/token")
+	coredb.Write("oauth", "clientid", "cid")
+	coredb.Write("oauth", "redirecturl", "https://aroz.my.domain")
+
+	cfg := oh.buildOAuthConfig()
+	if cfg == nil {
+		t.Fatal("buildOAuthConfig returned nil")
+	}
+	if !strings.HasSuffix(cfg.RedirectURL, "/system/auth/oauth/authorize") {
+		t.Errorf("RedirectURL should end with /system/auth/oauth/authorize, got: %q", cfg.RedirectURL)
+	}
+	if !strings.HasPrefix(cfg.RedirectURL, "https://aroz.my.domain") {
+		t.Errorf("RedirectURL should start with stored base URL, got: %q", cfg.RedirectURL)
+	}
+}

+ 0 - 53
src/mod/auth/oauth2/serviceSelector.go

@@ -1,53 +0,0 @@
-package oauth2
-
-import (
-	"errors"
-
-	"golang.org/x/oauth2"
-	db "imuslab.com/arozos/mod/database"
-)
-
-//getScope use to select the correct scope
-func getScope(coredb *db.Database) []string {
-	idp := readSingleConfig("idp", coredb)
-	if idp == "Google" {
-		return googleScope()
-	} else if idp == "Github" {
-		return githubScope()
-	} else if idp == "Microsoft" {
-		return microsoftScope()
-	} else if idp == "Gitlab" {
-		return gitlabScope()
-	}
-	return []string{}
-}
-
-//getEndpoint use to select the correct endpoint
-func getEndpoint(coredb *db.Database) oauth2.Endpoint {
-	idp := readSingleConfig("idp", coredb)
-	if idp == "Google" {
-		return googleEndpoint()
-	} else if idp == "Github" {
-		return githubEndpoint()
-	} else if idp == "Microsoft" {
-		return microsoftEndpoint()
-	} else if idp == "Gitlab" {
-		return gitlabEndpoint(readSingleConfig("serverurl", coredb))
-	}
-	return oauth2.Endpoint{}
-}
-
-//getUserinfo use to select the correct way to retrieve userinfo
-func getUserInfo(accessToken string, coredb *db.Database) (string, error) {
-	idp := readSingleConfig("idp", coredb)
-	if idp == "Google" {
-		return googleUserInfo(accessToken)
-	} else if idp == "Github" {
-		return githubUserInfo(accessToken)
-	} else if idp == "Microsoft" {
-		return microsoftUserInfo(accessToken)
-	} else if idp == "Gitlab" {
-		return gitlabUserInfo(accessToken, readSingleConfig("serverurl", coredb))
-	}
-	return "", errors.New("Unauthorized")
-}

+ 5 - 1
src/oauth.go

@@ -19,15 +19,19 @@ func OAuthInit() {
 		},
 		},
 	})
 	})
 
 
+	// Public endpoints (called before the user is authenticated)
 	http.HandleFunc("/system/auth/oauth/login", oAuthHandler.HandleLogin)
 	http.HandleFunc("/system/auth/oauth/login", oAuthHandler.HandleLogin)
 	http.HandleFunc("/system/auth/oauth/authorize", oAuthHandler.HandleAuthorize)
 	http.HandleFunc("/system/auth/oauth/authorize", oAuthHandler.HandleAuthorize)
 	http.HandleFunc("/system/auth/oauth/checkoauth", oAuthHandler.CheckOAuth)
 	http.HandleFunc("/system/auth/oauth/checkoauth", oAuthHandler.CheckOAuth)
+
+	// Admin-only configuration endpoints
 	adminRouter.HandleFunc("/system/auth/oauth/config/read", oAuthHandler.ReadConfig)
 	adminRouter.HandleFunc("/system/auth/oauth/config/read", oAuthHandler.ReadConfig)
 	adminRouter.HandleFunc("/system/auth/oauth/config/write", oAuthHandler.WriteConfig)
 	adminRouter.HandleFunc("/system/auth/oauth/config/write", oAuthHandler.WriteConfig)
+	adminRouter.HandleFunc("/system/auth/oauth/config/discover", oAuthHandler.HandleDiscover)
 
 
 	registerSetting(settingModule{
 	registerSetting(settingModule{
 		Name:         "OAuth",
 		Name:         "OAuth",
-		Desc:         "Allows external account access to system",
+		Desc:         "Sign in with any OIDC-compatible identity provider",
 		IconPath:     "SystemAO/advance/img/small_icon.png",
 		IconPath:     "SystemAO/advance/img/small_icon.png",
 		Group:        "Security",
 		Group:        "Security",
 		StartDir:     "SystemAO/advance/oauth.html",
 		StartDir:     "SystemAO/advance/oauth.html",

+ 557 - 201
src/web/SystemAO/advance/ldap.html

@@ -1,232 +1,588 @@
+<!DOCTYPE html>
 <html>
 <html>
-
 <head>
 <head>
-    <title>LDAP Login</title>
     <meta charset="UTF-8">
     <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no">
-    <link rel="stylesheet" href="../../script/semantic/semantic.css">
-    <script type="application/javascript" src="../../script/jquery.min.js"></script>
-    <script type="application/javascript" src="../../script/clipboard.min.js"></script>
-    <script type="application/javascript" src="../../script/semantic/semantic.js"></script>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+    <title>LDAP Login</title>
+    <script src="../../script/jquery.min.js"></script>
+    <script src="../../script/ao_module.js"></script>
     <style>
     <style>
-        /* Tooltip container */
-        
-        .tooltip {
-            position: relative;
-            display: inline-block;
-            border-bottom: 1px dotted black;
-            /* If you want dots under the hoverable text */
-        }
-        /* Tooltip text */
-        
-        .tooltip .tooltiptext {
-            visibility: hidden;
-            width: 120px;
-            background-color: #555;
-            color: #fff;
-            text-align: center;
-            padding: 5px 0;
-            border-radius: 6px;
-            /* Position the tooltip text */
-            position: absolute;
-            z-index: 1;
-            bottom: 125%;
-            left: 50%;
-            margin-left: -60px;
-            /* Fade in tooltip */
-            opacity: 0;
-            transition: opacity 0.3s;
-        }
-        /* Tooltip arrow */
-        
-        .tooltip .tooltiptext::after {
-            content: "";
-            position: absolute;
-            top: 100%;
-            left: 50%;
-            margin-left: -5px;
-            border-width: 5px;
-            border-style: solid;
-            border-color: #555 transparent transparent transparent;
-        }
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+    body { background: transparent; overflow-x: hidden; }
+
+    #la-root {
+        --la-bg:      #f3f3f3;
+        --la-card:    #ffffff;
+        --la-border:  #e0e0e0;
+        --la-text:    #1b1b1b;
+        --la-dim:     #5a5a5a;
+        --la-muted:   #8a8a8a;
+        --la-accent:  #0067c0;
+        --la-accentH: #1475c8;
+        --la-danger:  #c42b1c;
+        --la-ok:      #107c10;
+        --la-shadow:  0 1px 4px rgba(0,0,0,0.08);
+        --la-radius:  6px;
+
+        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+        font-size: 14px;
+        color: var(--la-text);
+        background: var(--la-bg);
+        min-height: 100vh;
+        padding-bottom: 32px;
+    }
+
+    #la-root.dark {
+        --la-bg:      #202020;
+        --la-card:    #2d2d2d;
+        --la-border:  #404040;
+        --la-text:    #e8e8e8;
+        --la-dim:     #aaaaaa;
+        --la-muted:   #666666;
+        --la-accent:  #60cdff;
+        --la-accentH: #8cd9ff;
+        --la-danger:  #ff7070;
+        --la-ok:      #6ccb5f;
+        --la-shadow:  0 1px 6px rgba(0,0,0,0.4);
+    }
+
+    /* ── Hero bar ── */
+    #la-hero {
+        display: flex;
+        align-items: center;
+        gap: 14px;
+        padding: 20px 20px 16px;
+    }
+
+    #la-hero-icon {
+        width: 44px;
+        height: 44px;
+        border-radius: 10px;
+        background: var(--la-accent);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-shrink: 0;
+        opacity: 0.9;
+    }
+
+    #la-hero-icon svg { color: #fff; }
+
+    #la-hero-title {
+        font-size: 17px;
+        font-weight: 600;
+        color: var(--la-text);
+        margin-bottom: 3px;
+    }
+
+    #la-hero-sub {
+        font-size: 12.5px;
+        color: var(--la-dim);
+    }
+
+    /* ── Section cards ── */
+    .la-section {
+        background: var(--la-card);
+        border: 1px solid var(--la-border);
+        border-radius: var(--la-radius);
+        margin: 0 12px 10px;
+        box-shadow: var(--la-shadow);
+        overflow: hidden;
+    }
+
+    .la-section-header {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 14px 18px;
+        cursor: pointer;
+        user-select: none;
+        transition: background 0.1s;
+    }
+
+    .la-section-header:hover { background: rgba(0,0,0,0.035); }
+    #la-root.dark .la-section-header:hover { background: rgba(255,255,255,0.05); }
+
+    .la-section-icon { color: var(--la-dim); flex-shrink: 0; }
+    .la-section-title { flex: 1; font-size: 14px; font-weight: 600; color: var(--la-text); }
+
+    .la-chevron {
+        width: 14px; height: 14px;
+        color: var(--la-muted);
+        transition: transform 0.2s ease;
+        flex-shrink: 0;
+    }
+    .la-chevron.open { transform: rotate(180deg); }
+
+    .la-section-body {
+        display: none;
+        padding: 4px 18px 18px;
+        border-top: 1px solid var(--la-border);
+    }
+    .la-section-body.open { display: block; }
+
+    /* ── Form elements ── */
+    .la-label {
+        display: block;
+        font-size: 12px;
+        font-weight: 500;
+        color: var(--la-dim);
+        margin-bottom: 5px;
+        margin-top: 14px;
+    }
+
+    .la-input {
+        width: 100%;
+        padding: 7px 10px;
+        border: 1px solid var(--la-border);
+        border-radius: 4px;
+        background: var(--la-card);
+        color: var(--la-text);
+        font-family: inherit;
+        font-size: 13px;
+        outline: none;
+        transition: border-color 0.15s;
+    }
+    .la-input:focus { border-color: var(--la-accent); }
+    .la-input::placeholder { color: var(--la-muted); }
+
+    /* Toggle switch */
+    .la-toggle-row {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        margin-top: 14px;
+    }
+
+    .la-toggle-label { font-size: 13px; color: var(--la-text); cursor: pointer; user-select: none; }
+
+    .la-toggle {
+        position: relative;
+        display: inline-block;
+        width: 36px;
+        height: 20px;
+        flex-shrink: 0;
+    }
+    .la-toggle input { opacity: 0; width: 0; height: 0; }
+    .la-slider {
+        position: absolute;
+        inset: 0;
+        background: var(--la-border);
+        border-radius: 20px;
+        cursor: pointer;
+        transition: background 0.2s;
+    }
+    .la-slider::before {
+        content: '';
+        position: absolute;
+        width: 14px; height: 14px;
+        left: 3px; top: 3px;
+        background: #fff;
+        border-radius: 50%;
+        transition: transform 0.2s;
+    }
+    .la-toggle input:checked + .la-slider { background: var(--la-accent); }
+    .la-toggle input:checked + .la-slider::before { transform: translateX(16px); }
+
+    /* Buttons */
+    .la-actions { margin-top: 18px; display: flex; gap: 8px; flex-wrap: wrap; }
+
+    .la-btn {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+        padding: 7px 16px;
+        border-radius: 4px;
+        border: 1px solid transparent;
+        font-family: inherit;
+        font-size: 13px;
+        font-weight: 500;
+        cursor: pointer;
+        transition: background 0.1s;
+        outline: none;
+    }
+
+    .la-btn-primary {
+        background: var(--la-accent);
+        color: #fff;
+        border-color: var(--la-accent);
+    }
+    .la-btn-primary:hover { background: var(--la-accentH); border-color: var(--la-accentH); }
+    .la-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
+
+    .la-btn-secondary {
+        background: transparent;
+        color: var(--la-text);
+        border-color: var(--la-border);
+    }
+    .la-btn-secondary:hover { background: rgba(0,0,0,0.05); }
+    #la-root.dark .la-btn-secondary:hover { background: rgba(255,255,255,0.07); }
+
+    /* Info callout */
+    .la-callout {
+        display: flex;
+        align-items: flex-start;
+        gap: 8px;
+        padding: 10px 12px;
+        border-radius: 4px;
+        font-size: 12.5px;
+        margin-top: 14px;
+        line-height: 1.5;
+    }
+    .la-callout-warn {
+        background: rgba(234, 179, 8, 0.1);
+        border: 1px solid rgba(234, 179, 8, 0.3);
+        color: var(--la-dim);
+    }
+    #la-root.dark .la-callout-warn {
+        background: rgba(250, 204, 21, 0.08);
+        border-color: rgba(250, 204, 21, 0.2);
+    }
+    .la-callout svg { flex-shrink: 0; margin-top: 1px; }
+    .la-callout-warn svg { color: #ca8a04; }
+    #la-root.dark .la-callout-warn svg { color: #facc15; }
+
+    /* Connection test results table */
+    #la-test-results {
+        display: none;
+        margin-top: 16px;
+    }
+
+    .la-table {
+        width: 100%;
+        border-collapse: collapse;
+        font-size: 12.5px;
+        margin-bottom: 12px;
+    }
+
+    .la-table th, .la-table td {
+        padding: 7px 10px;
+        text-align: left;
+        border: 1px solid var(--la-border);
+    }
+
+    .la-table th {
+        background: var(--la-bg);
+        font-weight: 600;
+        font-size: 11px;
+        text-transform: uppercase;
+        letter-spacing: 0.3px;
+        color: var(--la-dim);
+    }
+
+    .la-table td { color: var(--la-text); }
+
+    .la-table tr:hover td { background: rgba(0,0,0,0.02); }
+    #la-root.dark .la-table tr:hover td { background: rgba(255,255,255,0.03); }
+
+    #la-test-summary {
+        font-size: 12px;
+        color: var(--la-muted);
+        margin-top: 4px;
+    }
+
+    /* Status badge */
+    .la-status {
+        display: inline-flex;
+        align-items: center;
+        gap: 5px;
+        padding: 3px 8px;
+        border-radius: 12px;
+        font-size: 11.5px;
+        font-weight: 500;
+    }
+    .la-status-ok  { background: rgba(16,124,16,0.1);  color: var(--la-ok); }
+    .la-status-err { background: rgba(196,43,28,0.1); color: var(--la-danger); }
+    #la-root.dark .la-status-ok  { background: rgba(108, 203, 95, 0.12); }
+    #la-root.dark .la-status-err { background: rgba(255,112,112,0.12); }
     </style>
     </style>
 </head>
 </head>
-
 <body>
 <body>
-    <div class="ui container">
-        <div class="ui basic segment">
-            <div class="ui header">
-                <i class="key icon"></i>
-                <div class="content">
-                    LDAP Access
-                    <div class="sub header">Allow external account to access ArozOS with LDAP
-                        <br>
-                        <i class="info circle icon"></i> Your current account MUST exist inside the LDAP server; otherwise synchronize function will not work properly.
-                    </div>
-                </div>
-            </div>
+
+<div id="la-root">
+
+    <!-- Hero -->
+    <div id="la-hero">
+        <div id="la-hero-icon">
+            <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
+                <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.6"/>
+                <path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+            </svg>
         </div>
         </div>
-        <div class="ui divider"></div>
-        <div class="ui green inverted segment" style="display:none;" id="updateSet">
-            <h5 class="ui header">
-                <i class="checkmark icon"></i>
-                <div class="content">
-                    Settings Updated
-                </div>
-            </h5>
+        <div>
+            <div id="la-hero-title">LDAP / Active Directory</div>
+            <div id="la-hero-sub">Allow external accounts to access ArozOS via LDAP</div>
         </div>
         </div>
-        <div class="ui form">
-            <div class="field">
-                <div class="ui toggle checkbox">
-                    <input type="checkbox" id="enable" name="public">
-                    <label>Enable LDAP</label>
-                </div>
-            </div>
-            <div class="field">
-                <label>Bind Username</label>
-                <div class="ui fluid input">
-                    <input type="text" id="bind_username" placeholder="root">
-                </div>
-            </div>
-            <div class="field">
-                <label>Bind Password</label>
-                <div class="ui fluid input">
-                    <input type="password" id="bind_password" placeholder="p@ssw0rd">
-                </div>
+    </div>
+
+    <!-- General Settings -->
+    <div class="la-section">
+        <div class="la-section-header" onclick="laToggle(this)">
+            <svg class="la-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/>
+                <path d="M8 5v3.5l2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+            </svg>
+            <span class="la-section-title">General</span>
+            <svg class="la-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="la-section-body open">
+            <div class="la-callout la-callout-warn">
+                <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
+                    <path d="M8 1L15 14H1L8 1z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
+                    <path d="M8 6v4M8 11.5v.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
+                </svg>
+                <span>Your current ArozOS account must exist in the LDAP server; otherwise the synchronise function will not work properly.</span>
             </div>
             </div>
-            <div class="field">
-                <label>FQDN</label>
-                <div class="ui fluid input">
-                    <input type="text" id="fqdn" placeholder="10.0.0.1">
-                </div>
+
+            <div class="la-toggle-row">
+                <label class="la-toggle" for="la-enable">
+                    <input type="checkbox" id="la-enable">
+                    <span class="la-slider"></span>
+                </label>
+                <label class="la-toggle-label" for="la-enable">Enable LDAP</label>
             </div>
             </div>
-            <div class="field">
-                <label>Base DN</label>
-                <div class="ui fluid input">
-                    <input type="text" id="base_dn" placeholder="cn=users,dc=ldap">
-                </div>
+        </div>
+    </div>
+
+    <!-- Connection Settings -->
+    <div class="la-section">
+        <div class="la-section-header" onclick="laToggle(this)">
+            <svg class="la-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <circle cx="4" cy="8" r="2" stroke="currentColor" stroke-width="1.3"/>
+                <circle cx="12" cy="8" r="2" stroke="currentColor" stroke-width="1.3"/>
+                <path d="M6 8h4" stroke="currentColor" stroke-width="1.3"/>
+            </svg>
+            <span class="la-section-title">Connection</span>
+            <svg class="la-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="la-section-body open">
+            <label class="la-label">LDAP Server (FQDN or IP)</label>
+            <input class="la-input" type="text" id="la-fqdn" placeholder="ldap.example.com or 10.0.0.1">
+
+            <label class="la-label">Base DN</label>
+            <input class="la-input" type="text" id="la-base-dn" placeholder="cn=users,dc=example,dc=com">
+
+            <label class="la-label">Bind Username</label>
+            <input class="la-input" type="text" id="la-bind-username" placeholder="cn=admin,dc=example,dc=com" autocomplete="off">
+
+            <label class="la-label">Bind Password</label>
+            <input class="la-input" type="password" id="la-bind-password" placeholder="Bind password" autocomplete="new-password">
+
+            <div class="la-actions">
+                <button class="la-btn la-btn-primary" id="la-save-btn" onclick="laSave()">
+                    <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
+                        <path d="M2 8.5L6 12.5L14 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
+                    </svg>
+                    Save Changes
+                </button>
+                <button class="la-btn la-btn-secondary" id="la-test-btn" onclick="laTest()">
+                    <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
+                        <circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/>
+                        <path d="M8 5v3.5l2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+                    </svg>
+                    Test Connection
+                </button>
             </div>
             </div>
-            <button id="ntb" onclick="update();" class="ui green button" type="submit">Update</button>
-            <button id="test_btn" onclick="test();" class="ui button" type="submit">Test Connection</button>
         </div>
         </div>
-        <div class="ui divider"></div>
-        <div id="testConnection" style="display: none">
-            <table class="ui celled table">
+    </div>
+
+    <!-- Test Results -->
+    <div class="la-section" id="la-results-section" style="display:none;">
+        <div class="la-section-header" onclick="laToggle(this)">
+            <svg class="la-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <path d="M2 3h12v2.5L9 10v4l-2-1V10L2 5.5V3z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
+            </svg>
+            <span class="la-section-title">Connection Results</span>
+            <svg class="la-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="la-section-body open" id="la-test-results">
+            <table class="la-table" id="la-user-table">
                 <thead>
                 <thead>
                     <tr>
                     <tr>
                         <th>Username</th>
                         <th>Username</th>
-                        <th>Group belongs to</th>
-                        <th>Equivalence user group in arozos</th>
+                        <th>LDAP Group(s)</th>
+                        <th>Equivalent ArozOS Group(s)</th>
                     </tr>
                     </tr>
                 </thead>
                 </thead>
-                <tbody id="information">
-                </tbody>
+                <tbody id="la-user-tbody"></tbody>
             </table>
             </table>
-            <button id="sync_btn" onclick="syncorize();" class="ui button" type="submit">Syncorize User</button>
+            <div id="la-test-summary"></div>
+            <div class="la-actions">
+                <button class="la-btn la-btn-secondary" id="la-sync-btn" onclick="laSync()">
+                    <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
+                        <path d="M13 8A5 5 0 112 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
+                        <path d="M13 5v3h-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
+                    </svg>
+                    Synchronise Users
+                </button>
+            </div>
         </div>
         </div>
-        <br><br>
     </div>
     </div>
 
 
+</div>
 
 
-    <script>
-        $(document).ready(function() {
-            read();
-        });
+<script>
+/* ── Theme ── */
+var _laDark = false;
 
 
-        function read() {
-            $.getJSON("../../system/auth/ldap/config/read", function(data) {
-                if (data.enabled) {
-                    $("#enable").parent().checkbox("check")
-                }
-                if (data.autoredirect) {
-                    $("#autoredirect").parent().checkbox("check")
-                }
-                $("#bind_username").val(data.bind_username);
-                $("#bind_password").val(data.bind_password);
-                $("#fqdn").val(data.fqdn);
-                $("#base_dn").val(data.base_dn);
-            });
-        }
+function laApplyTheme(dark) {
+    _laDark = dark;
+    document.getElementById('la-root').classList.toggle('dark', dark);
+}
 
 
-        function update() {
-            $.post("../../system/auth/ldap/config/write", {
-                    enabled: $("#enable").parent().checkbox("is checked"),
-                    bind_username: $("#bind_username").val(),
-                    bind_password: $("#bind_password").val(),
-                    fqdn: $("#fqdn").val(),
-                    base_dn: $("#base_dn").val(),
-                })
-                .done(function(data) {
-                    if (data.error != undefined) {
-                        alert(data.error);
-                    } else {
-                        //OK!
-                        $("#updateSet").stop().finish().slideDown("fast").delay(3000).slideUp('fast');
-                    }
-                });
-        }
+try {
+    var _pt = (typeof preferredTheme !== 'undefined' ? preferredTheme : null)
+           || (typeof parent !== 'undefined' && parent.preferredTheme ? parent.preferredTheme : null);
+    if (_pt) {
+        laApplyTheme(_pt === 'dark' || _pt === 'darkTheme');
+    } else {
+        ao_module_getSystemThemeColor(function(c) { laApplyTheme(c !== 'whiteTheme'); });
+    }
+} catch(e) {
+    ao_module_getSystemThemeColor(function(c) { laApplyTheme(c !== 'whiteTheme'); });
+}
 
 
-        function test() {
-            $("#test_btn").text("Testing...");
-            $.get("../../system/auth/ldap/config/testConnection")
-                .done(function(data) {
-                    $("#information").html("");
-                    if (data.error != undefined) {
-                        if (data.error != "") {
-                            $("#information").append(`
-                            <tr>
-                                <td data-label="information" colspan="3">` + data.error + `</td>
-                            </tr>
-                        `);
-                            $("#testConnection").show("fast");
-                            $("#test_btn").text("Test connection");
-                            return;
-                        }
-                    }
-                    if (data.userinfo == null) {
-                        $("#information").append(`
-                            <tr>
-                                <td data-label="information" colspan="3">No entries was found.</td>
-                            </tr>
-                        `);
-                        $("#testConnection").show("fast");
-                        $("#test_btn").text("Test connection");
-                        return;
-                    }
-                    //OK!
-                    $(data.userinfo).each(function(index, element) {
-                        $("#information").append(`
-                            <tr>
-                                <td data-label="username">` + element.username + `</td>
-                                <td data-label="ldap_group">` + element.group + `</td>
-                                <td data-label="equiv_group">` + element.equiv_group + `</td>
-                            </tr>
-                        `);
-                    });
-                    $("#information").append(`
-                            <tr>
-                                <td data-label="length" colspan="3">Showing ` + data.length + ` of ` + data.total_length + ` entries</td>
-                            </tr>
-                    `);
-                    $("#testConnection").show("fast");
-                    $("#test_btn").text("Test connection");
-                });
+/* ── Section accordion ── */
+function laToggle(header) {
+    var body    = header.nextElementSibling;
+    var chevron = header.querySelector('.la-chevron');
+    var isOpen  = body.classList.toggle('open');
+    chevron.classList.toggle('open', isOpen);
+}
+
+/* ── Init ── */
+$(document).ready(function() {
+    laRead();
+});
+
+function laRead() {
+    $.getJSON('../../system/auth/ldap/config/read', function(data) {
+        document.getElementById('la-enable').checked       = !!data.enabled;
+        document.getElementById('la-bind-username').value  = data.bind_username || '';
+        document.getElementById('la-bind-password').value  = data.bind_password || '';
+        document.getElementById('la-fqdn').value           = data.fqdn || '';
+        document.getElementById('la-base-dn').value        = data.base_dn || '';
+    });
+}
+
+function laSave() {
+    var btn = document.getElementById('la-save-btn');
+    btn.disabled = true;
+
+    $.post('../../system/auth/ldap/config/write', {
+        enabled:       document.getElementById('la-enable').checked ? 'true' : 'false',
+        bind_username: document.getElementById('la-bind-username').value,
+        bind_password: document.getElementById('la-bind-password').value,
+        fqdn:          document.getElementById('la-fqdn').value,
+        base_dn:       document.getElementById('la-base-dn').value
+    }).done(function(data) {
+        btn.disabled = false;
+        if (data.error !== undefined) {
+            laToast(data.error, false);
+        } else {
+            laToast('Settings saved successfully', true);
         }
         }
+    }).fail(function() {
+        btn.disabled = false;
+        laToast('Request failed. Please try again.', false);
+    });
+}
+
+function laTest() {
+    var btn = document.getElementById('la-test-btn');
+    btn.disabled = true;
+    var origHTML = btn.innerHTML;
+    btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" stroke-dasharray="3 2"/></svg> Testing…';
+
+    $.get('../../system/auth/ldap/config/testConnection')
+        .done(function(data) {
+            btn.disabled = false;
+            btn.innerHTML = origHTML;
+            var tbody = document.getElementById('la-user-tbody');
+            tbody.innerHTML = '';
+
+            if (data.error && data.error !== '') {
+                tbody.innerHTML = '<tr><td colspan="3"><span class="la-status la-status-err">' +
+                    escHtml(data.error) + '</span></td></tr>';
+                document.getElementById('la-results-section').style.display = '';
+                document.getElementById('la-test-results').classList.add('open');
+                return;
+            }
 
 
-        function syncorize() {
-            $("#sync_btn").text("Syncorizing...");
-            $.get("../../system/auth/ldap/config/syncorizeUser")
-                .done(function(data) {
-                    if (data.error != undefined) {
-                        alert(data.error);
-                    } else {
-                        //OK!
-                        $("#updateSet").stop().finish().slideDown("fast").delay(3000).slideUp('fast');
-                    }
-                    $("#sync_btn").text("Syncorize User");
+            if (!data.userinfo || data.userinfo.length === 0) {
+                tbody.innerHTML = '<tr><td colspan="3">No entries found.</td></tr>';
+            } else {
+                data.userinfo.forEach(function(u) {
+                    var groups  = Array.isArray(u.group)      ? u.group.join(', ')      : (u.group || '—');
+                    var egroups = Array.isArray(u.equiv_group) ? u.equiv_group.join(', ') : (u.equiv_group || '—');
+                    var row = document.createElement('tr');
+                    row.innerHTML = '<td>' + escHtml(u.username) + '</td><td>' + escHtml(groups) + '</td><td>' + escHtml(egroups) + '</td>';
+                    tbody.appendChild(row);
                 });
                 });
+            }
+
+            var summary = document.getElementById('la-test-summary');
+            if (data.total_length !== undefined) {
+                summary.textContent = 'Showing ' + (data.length || 0) + ' of ' + data.total_length + ' entries.';
+            }
+
+            document.getElementById('la-results-section').style.display = '';
+            document.getElementById('la-test-results').classList.add('open');
+        })
+        .fail(function() {
+            btn.disabled = false;
+            btn.innerHTML = origHTML;
+            laToast('Connection test failed. Check server settings.', false);
+        });
+}
+
+function laSync() {
+    var btn = document.getElementById('la-sync-btn');
+    btn.disabled = true;
+
+    $.get('../../system/auth/ldap/config/syncorizeUser')
+        .done(function(data) {
+            btn.disabled = false;
+            if (data.error !== undefined) {
+                laToast(data.error, false);
+            } else {
+                laToast('Users synchronised successfully', true);
+            }
+        })
+        .fail(function() {
+            btn.disabled = false;
+            laToast('Synchronisation failed. Please try again.', false);
+        });
+}
+
+/* ── Helpers ── */
+function escHtml(s) {
+    return String(s)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;');
+}
+
+function laToast(msg, ok) {
+    try {
+        if (typeof parent !== 'undefined' && typeof parent.msgbox === 'function') {
+            parent.msgbox(msg, ok);
+            return;
         }
         }
-    </script>
+        if (typeof msgbox === 'function') {
+            msgbox(msg, ok);
+            return;
+        }
+    } catch(e) {}
+    alert(msg);
+}
+</script>
 </body>
 </body>
-
-</html>
+</html>

+ 524 - 178
src/web/SystemAO/advance/oauth.html

@@ -1,211 +1,557 @@
+<!DOCTYPE html>
 <html>
 <html>
-
 <head>
 <head>
-    <title>OAuth Login</title>
     <meta charset="UTF-8">
     <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no">
-    <link rel="stylesheet" href="../../script/semantic/semantic.css">
-    <script type="application/javascript" src="../../script/jquery.min.js"></script>
-    <script type="application/javascript" src="../../script/clipboard.min.js"></script>
-    <script type="application/javascript" src="../../script/semantic/semantic.js"></script>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+    <title>OAuth / OIDC</title>
+    <script src="../../script/jquery.min.js"></script>
+    <script src="../../script/ao_module.js"></script>
     <style>
     <style>
-        /* Tooltip container */
-        
-        .tooltip {
-            position: relative;
-            display: inline-block;
-            border-bottom: 1px dotted black;
-            /* If you want dots under the hoverable text */
-        }
-        /* Tooltip text */
-        
-        .tooltip .tooltiptext {
-            visibility: hidden;
-            width: 120px;
-            background-color: #555;
-            color: #fff;
-            text-align: center;
-            padding: 5px 0;
-            border-radius: 6px;
-            /* Position the tooltip text */
-            position: absolute;
-            z-index: 1;
-            bottom: 125%;
-            left: 50%;
-            margin-left: -60px;
-            /* Fade in tooltip */
-            opacity: 0;
-            transition: opacity 0.3s;
-        }
-        /* Tooltip arrow */
-        
-        .tooltip .tooltiptext::after {
-            content: "";
-            position: absolute;
-            top: 100%;
-            left: 50%;
-            margin-left: -5px;
-            border-width: 5px;
-            border-style: solid;
-            border-color: #555 transparent transparent transparent;
-        }
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+    body { background: transparent; overflow-x: hidden; }
+
+    #oa-root {
+        --oa-bg:       #f3f3f3;
+        --oa-card:     #ffffff;
+        --oa-border:   #e0e0e0;
+        --oa-text:     #1b1b1b;
+        --oa-dim:      #5a5a5a;
+        --oa-muted:    #8a8a8a;
+        --oa-accent:   #0067c0;
+        --oa-accentH:  #1475c8;
+        --oa-ok:       #107c10;
+        --oa-warn-bg:  rgba(0,103,192,0.07);
+        --oa-warn-bdr: rgba(0,103,192,0.22);
+        --oa-shadow:   0 1px 4px rgba(0,0,0,0.08);
+        --oa-radius:   6px;
+
+        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+        font-size: 14px;
+        color: var(--oa-text);
+        background: var(--oa-bg);
+        min-height: 100vh;
+        padding-bottom: 32px;
+    }
+
+    #oa-root.dark {
+        --oa-bg:       #202020;
+        --oa-card:     #2d2d2d;
+        --oa-border:   #404040;
+        --oa-text:     #e8e8e8;
+        --oa-dim:      #aaaaaa;
+        --oa-muted:    #666666;
+        --oa-accent:   #60cdff;
+        --oa-accentH:  #8cd9ff;
+        --oa-ok:       #6ccb5f;
+        --oa-warn-bg:  rgba(96,205,255,0.07);
+        --oa-warn-bdr: rgba(96,205,255,0.18);
+        --oa-shadow:   0 1px 6px rgba(0,0,0,0.4);
+    }
+
+    /* ── Hero ── */
+    #oa-hero { display:flex; align-items:center; gap:14px; padding:20px 20px 16px; }
+    #oa-hero-icon {
+        width:44px; height:44px; border-radius:10px; background:var(--oa-accent);
+        display:flex; align-items:center; justify-content:center; flex-shrink:0; opacity:.9;
+    }
+    #oa-hero-icon svg { color:#fff; }
+    #oa-hero-title  { font-size:17px; font-weight:600; color:var(--oa-text); margin-bottom:3px; }
+    #oa-hero-sub    { font-size:12.5px; color:var(--oa-dim); }
+
+    /* ── Cards ── */
+    .oa-section {
+        background:var(--oa-card); border:1px solid var(--oa-border);
+        border-radius:var(--oa-radius); margin:0 12px 10px;
+        box-shadow:var(--oa-shadow); overflow:hidden;
+    }
+    .oa-section-header {
+        display:flex; align-items:center; gap:10px; padding:14px 18px;
+        cursor:pointer; user-select:none; transition:background .1s;
+    }
+    .oa-section-header:hover { background:rgba(0,0,0,.035); }
+    #oa-root.dark .oa-section-header:hover { background:rgba(255,255,255,.05); }
+    .oa-section-icon  { color:var(--oa-dim); flex-shrink:0; }
+    .oa-section-title { flex:1; font-size:14px; font-weight:600; color:var(--oa-text); }
+    .oa-chevron { width:14px; height:14px; color:var(--oa-muted); transition:transform .2s ease; flex-shrink:0; }
+    .oa-chevron.open { transform:rotate(180deg); }
+    .oa-section-body { display:none; padding:4px 18px 18px; border-top:1px solid var(--oa-border); }
+    .oa-section-body.open { display:block; }
+
+    /* ── Form elements ── */
+    .oa-label {
+        display:block; font-size:12px; font-weight:500; color:var(--oa-dim);
+        margin-bottom:5px; margin-top:14px;
+    }
+    .oa-hint { font-size:11px; color:var(--oa-muted); margin-top:2px; margin-bottom:6px; }
+    .oa-input {
+        width:100%; padding:7px 10px; border:1px solid var(--oa-border); border-radius:4px;
+        background:var(--oa-card); color:var(--oa-text); font-family:inherit; font-size:13px;
+        outline:none; transition:border-color .15s;
+    }
+    .oa-input:focus { border-color:var(--oa-accent); }
+    .oa-input::placeholder { color:var(--oa-muted); }
+    .oa-input[readonly] { background:var(--oa-bg); color:var(--oa-dim); cursor:default; }
+    .oa-input-row { display:flex; gap:8px; }
+    .oa-input-row .oa-input { flex:1; }
+
+    /* Toggle */
+    .oa-toggle-row { display:flex; align-items:center; gap:10px; margin-top:14px; }
+    .oa-toggle-label { font-size:13px; color:var(--oa-text); cursor:pointer; user-select:none; }
+    .oa-toggle { position:relative; display:inline-block; width:36px; height:20px; flex-shrink:0; }
+    .oa-toggle input { opacity:0; width:0; height:0; }
+    .oa-slider { position:absolute; inset:0; background:var(--oa-border); border-radius:20px; cursor:pointer; transition:background .2s; }
+    .oa-slider::before { content:''; position:absolute; width:14px; height:14px; left:3px; top:3px; background:#fff; border-radius:50%; transition:transform .2s; }
+    .oa-toggle input:checked + .oa-slider { background:var(--oa-accent); }
+    .oa-toggle input:checked + .oa-slider::before { transform:translateX(16px); }
+
+    /* Buttons */
+    .oa-actions { margin-top:18px; display:flex; gap:8px; flex-wrap:wrap; }
+    .oa-btn {
+        display:inline-flex; align-items:center; gap:6px; padding:7px 16px;
+        border-radius:4px; border:1px solid transparent; font-family:inherit;
+        font-size:13px; font-weight:500; cursor:pointer; transition:background .1s; outline:none;
+    }
+    .oa-btn-primary { background:var(--oa-accent); color:#fff; border-color:var(--oa-accent); }
+    .oa-btn-primary:hover { background:var(--oa-accentH); border-color:var(--oa-accentH); }
+    .oa-btn-primary:disabled { opacity:.6; cursor:not-allowed; }
+    .oa-btn-secondary { background:transparent; color:var(--oa-text); border-color:var(--oa-border); }
+    .oa-btn-secondary:hover { background:rgba(0,0,0,.05); }
+    #oa-root.dark .oa-btn-secondary:hover { background:rgba(255,255,255,.07); }
+    .oa-btn-secondary:disabled { opacity:.5; cursor:not-allowed; }
+
+    /* Callout */
+    .oa-callout {
+        display:flex; align-items:flex-start; gap:8px; padding:10px 12px;
+        border-radius:4px; font-size:12.5px; margin-top:12px; line-height:1.6;
+    }
+    .oa-callout-info { background:var(--oa-warn-bg); border:1px solid var(--oa-warn-bdr); color:var(--oa-dim); }
+    .oa-callout svg { flex-shrink:0; margin-top:1px; color:var(--oa-accent); }
+
+    /* Callback URL display */
+    #oa-callback-url {
+        font-size:12px; color:var(--oa-dim); background:var(--oa-bg); border:1px solid var(--oa-border);
+        border-radius:4px; padding:6px 10px; margin-top:6px; word-break:break-all;
+        font-family:'Courier New',monospace;
+    }
+
+    /* Discovered endpoints accordion */
+    #oa-discovered-wrap {
+        margin-top:14px; border:1px solid var(--oa-border); border-radius:4px; overflow:hidden;
+    }
+    #oa-discovered-header {
+        display:flex; align-items:center; gap:8px; padding:9px 12px;
+        cursor:pointer; user-select:none; background:var(--oa-bg);
+        font-size:12.5px; font-weight:500; color:var(--oa-dim);
+        transition:background .1s;
+    }
+    #oa-discovered-header:hover { background:var(--oa-border); }
+    #oa-discovered-body { display:none; padding:4px 12px 12px; }
+    #oa-discovered-body.open { display:block; }
+    #oa-ep-chevron { width:12px; height:12px; color:var(--oa-muted); transition:transform .2s; margin-left:auto; }
+    #oa-ep-chevron.open { transform:rotate(180deg); }
+
+    /* Spinner */
+    .oa-spin {
+        display:inline-block; width:13px; height:13px;
+        border:2px solid rgba(255,255,255,.4); border-top-color:#fff;
+        border-radius:50%; animation:spin .6s linear infinite;
+    }
+    #oa-root.dark .oa-spin { border-color:rgba(0,0,0,.3); border-top-color:var(--oa-text); }
+    @keyframes spin { to { transform:rotate(360deg); } }
+
+    /* Helper */
+    .oa-hidden { display:none !important; }
     </style>
     </style>
 </head>
 </head>
-
 <body>
 <body>
-    <div class="ui container">
-        <div class="ui basic segment">
-            <div class="ui header">
-                <i class="key icon"></i>
-                <div class="content">
-                    OAuth Access
-                    <div class="sub header">Allow external account to access ArozOS with OAuth 2.0</div>
-                </div>
-            </div>
+<div id="oa-root">
+
+    <!-- Hero -->
+    <div id="oa-hero">
+        <div id="oa-hero-icon">
+            <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
+                <circle cx="12" cy="9" r="3.5" stroke="currentColor" stroke-width="1.5"/>
+                <path d="M12 13.5V21M9 18h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+                <path d="M4 20c1.2-3.6 4.3-6 8-6s6.8 2.4 8 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="2 2"/>
+            </svg>
         </div>
         </div>
-        <div class="ui divider"></div>
-        <div class="ui green inverted segment" style="display:none;" id="updateSet">
-            <h5 class="ui header">
-                <i class="checkmark icon"></i>
-                <div class="content">
-                    Setting Updated
-                </div>
-            </h5>
+        <div>
+            <div id="oa-hero-title">OAuth 2.0 / OIDC</div>
+            <div id="oa-hero-sub">Sign in with any OpenID Connect-compatible identity provider</div>
         </div>
         </div>
-        <div class="ui form">
-            <div class="field">
-                <div class="ui toggle checkbox">
-                    <input type="checkbox" id="enable" name="public">
-                    <label>Enable OAuth</label>
-                </div>
+    </div>
+
+    <!-- ── Section 1: General toggles ── -->
+    <div class="oa-section">
+        <div class="oa-section-header" onclick="oaToggle(this)">
+            <svg class="oa-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <path d="M2 8h3l2-5 2 10 2-5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+            <span class="oa-section-title">General</span>
+            <svg class="oa-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="oa-section-body open">
+            <div class="oa-toggle-row">
+                <label class="oa-toggle" for="oa-enable">
+                    <input type="checkbox" id="oa-enable">
+                    <span class="oa-slider"></span>
+                </label>
+                <label class="oa-toggle-label" for="oa-enable">Enable OAuth / OIDC login</label>
             </div>
             </div>
-            <div class="field">
-                <div class="ui toggle checkbox">
-                    <input type="checkbox" id="autoredirect" name="autoredirect">
-                    <label>Auto redirect</label>
-                </div>
+            <div class="oa-toggle-row">
+                <label class="oa-toggle" for="oa-autoredirect">
+                    <input type="checkbox" id="oa-autoredirect">
+                    <span class="oa-slider"></span>
+                </label>
+                <label class="oa-toggle-label" for="oa-autoredirect">Auto-redirect to provider on login page</label>
             </div>
             </div>
-            <div class="field">
-                <label>Select OAuth IdP (aka Service provider)</label>
-                <div class="ui selection fluid dropdown" autocomplete="false">
-                    <input type="hidden" id="idp" name="idp" autocomplete="false">
-                    <i class="dropdown icon"></i>
-                    <div class="default text">Select service provider</div>
-                    <div id="idplist" class="menu">
-
-                    </div>
-                </div>
+        </div>
+    </div>
+
+    <!-- ── Section 2: Provider discovery ── -->
+    <div class="oa-section">
+        <div class="oa-section-header" onclick="oaToggle(this)">
+            <svg class="oa-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <circle cx="8" cy="7" r="4.5" stroke="currentColor" stroke-width="1.3"/>
+                <path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+            </svg>
+            <span class="oa-section-title">Provider Discovery</span>
+            <svg class="oa-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="oa-section-body open">
+
+            <label class="oa-label">Issuer URL</label>
+            <div class="oa-hint">The base URL of your identity provider (e.g. <code>https://accounts.google.com</code>, <code>https://login.microsoftonline.com/{tenant}/v2.0</code>, <code>https://your-keycloak/realms/myrealm</code>). ArozOS will fetch <code>/.well-known/openid-configuration</code> automatically.</div>
+            <div class="oa-input-row">
+                <input class="oa-input" type="url" id="oa-issuer" placeholder="https://idp.example.com">
+                <button class="oa-btn oa-btn-secondary" id="oa-discover-btn" onclick="oaDiscover()">
+                    <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
+                        <circle cx="8" cy="7" r="4" stroke="currentColor" stroke-width="1.3"/>
+                        <path d="M10.5 10.5l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+                    </svg>
+                    Discover
+                </button>
             </div>
             </div>
-            <div class="field">
-                <label>Redirect URL (you have to provide <span id="redirectspan">https://YOUR_DOMAIN/system/auth/oauth/authorize</span> at the 3rd auth rely)</label>
-                <div class="ui fluid input">
-                    <input type="text" id="redirecturl" placeholder="https://YOUR_DOMAIN/">
+
+            <!-- Discovered endpoints (collapsed by default, auto-expands after discovery) -->
+            <div id="oa-discovered-wrap" class="oa-hidden">
+                <div id="oa-discovered-header" onclick="oaToggleEp()">
+                    <svg width="13" height="13" viewBox="0 0 16 16" fill="none" style="color:var(--oa-ok)">
+                        <path d="M2 8.5L6 12.5L14 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
+                    </svg>
+                    Endpoints discovered — click to review or override
+                    <svg id="oa-ep-chevron" viewBox="0 0 16 16" fill="none">
+                        <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                    </svg>
                 </div>
                 </div>
-            </div>
-            <div class="field" id="server" style="display: none;">
-                <label>Server URL</label>
-                <div class="ui fluid input">
-                    <input type="text" id="serverurl" placeholder="http://YOUR_DOMAIN/">
+                <div id="oa-discovered-body">
+                    <label class="oa-label">Authorization Endpoint</label>
+                    <input class="oa-input" type="url" id="oa-auth-ep" placeholder="https://idp.example.com/oauth2/authorize">
+                    <label class="oa-label">Token Endpoint</label>
+                    <input class="oa-input" type="url" id="oa-token-ep" placeholder="https://idp.example.com/oauth2/token">
+                    <label class="oa-label">UserInfo Endpoint</label>
+                    <input class="oa-input" type="url" id="oa-userinfo-ep" placeholder="https://idp.example.com/oauth2/userinfo">
                 </div>
                 </div>
             </div>
             </div>
-            <div class="field">
-                <label>Client ID</label>
-                <div class="ui fluid input">
-                    <input type="text" id="clientid" placeholder="Client ID">
+
+            <!-- Manual entry when no discovery was run -->
+            <div id="oa-manual-wrap">
+                <div class="oa-callout oa-callout-info" style="margin-top:10px;">
+                    <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
+                        <circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/>
+                        <path d="M8 7v5M8 5.5v.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
+                    </svg>
+                    <span>Click <strong>Discover</strong> to auto-fill endpoints from your provider. You can also enter them manually below if your provider does not publish a discovery document.</span>
                 </div>
                 </div>
+                <label class="oa-label">Authorization Endpoint <span style="color:var(--oa-muted);font-weight:400">(manual, overrides discovery)</span></label>
+                <input class="oa-input" type="url" id="oa-auth-ep-manual" placeholder="https://idp.example.com/oauth2/authorize">
+                <label class="oa-label">Token Endpoint <span style="color:var(--oa-muted);font-weight:400">(manual)</span></label>
+                <input class="oa-input" type="url" id="oa-token-ep-manual" placeholder="https://idp.example.com/oauth2/token">
+                <label class="oa-label">UserInfo Endpoint <span style="color:var(--oa-muted);font-weight:400">(manual)</span></label>
+                <input class="oa-input" type="url" id="oa-userinfo-ep-manual" placeholder="https://idp.example.com/oauth2/userinfo">
             </div>
             </div>
-            <div class="field">
-                <label>Client Secret</label>
-                <div class="ui fluid input">
-                    <input type="text" id="clientsecret" placeholder="Client Secret">
-                </div>
+        </div>
+    </div>
+
+    <!-- ── Section 3: Credentials ── -->
+    <div class="oa-section">
+        <div class="oa-section-header" onclick="oaToggle(this)">
+            <svg class="oa-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <rect x="4" y="7" width="8" height="7" rx="1" stroke="currentColor" stroke-width="1.3"/>
+                <path d="M5.5 7V5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+            </svg>
+            <span class="oa-section-title">Application Credentials</span>
+            <svg class="oa-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="oa-section-body open">
+            <label class="oa-label">Client ID</label>
+            <input class="oa-input" type="text" id="oa-clientid" placeholder="Client ID" autocomplete="off">
+            <label class="oa-label">Client Secret</label>
+            <input class="oa-input" type="password" id="oa-clientsecret" placeholder="Client Secret" autocomplete="new-password">
+        </div>
+    </div>
+
+    <!-- ── Section 4: ArozOS server & callback ── -->
+    <div class="oa-section">
+        <div class="oa-section-header" onclick="oaToggle(this)">
+            <svg class="oa-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" stroke-width="1.3"/>
+                <path d="M5 8h3l2-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+            <span class="oa-section-title">Server &amp; Callback</span>
+            <svg class="oa-chevron open" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="oa-section-body open">
+            <label class="oa-label">ArozOS Server Base URL</label>
+            <div class="oa-hint">Leave empty to use the current origin. Used to build the redirect/callback URL.</div>
+            <input class="oa-input" type="url" id="oa-redirecturl" placeholder="https://your.arozos.server">
+            <div class="oa-callout oa-callout-info" style="margin-top:10px;">
+                <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
+                    <circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/>
+                    <path d="M8 7v5M8 5.5v.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
+                </svg>
+                <span>Register this as the allowed redirect URI in your provider's application settings:<br><strong id="oa-callback-url" style="font-family:'Courier New',monospace;font-size:11.5px;"></strong></span>
             </div>
             </div>
-            <button id="ntb" onclick="update();" class="ui green button" type="submit">Update</button>
         </div>
         </div>
-        <div class="ui divider"></div>
-        <br><br>
     </div>
     </div>
 
 
+    <!-- ── Section 5: Advanced (scope + username field) ── -->
+    <div class="oa-section">
+        <div class="oa-section-header" onclick="oaToggle(this)">
+            <svg class="oa-section-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                <path d="M3 4h10M3 8h6M3 12h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+                <circle cx="12" cy="12" r="2.5" stroke="currentColor" stroke-width="1.2"/>
+                <path d="M14 14l1.5 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
+            </svg>
+            <span class="oa-section-title">Advanced Options</span>
+            <svg class="oa-chevron" viewBox="0 0 16 16" fill="none">
+                <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+        </div>
+        <div class="oa-section-body">
+            <label class="oa-label">Scope</label>
+            <div class="oa-hint">Space-separated OAuth2 scopes. Default: <code>openid email profile</code></div>
+            <input class="oa-input" type="text" id="oa-scope" placeholder="openid email profile">
 
 
-    <script>
-        $(document).ready(function() {
-            loadIdpList();
-            read();
-            initPlaceholder();
-        });
+            <label class="oa-label">Username Field</label>
+            <div class="oa-hint">The JSON field from the userinfo response to use as the ArozOS username. Default: <code>email</code>. Other common values: <code>preferred_username</code>, <code>sub</code>, <code>login</code>.</div>
+            <input class="oa-input" type="text" id="oa-usernamefield" placeholder="email">
+        </div>
+    </div>
+
+    <!-- ── Save button ── -->
+    <div style="padding:0 12px;">
+        <div class="oa-actions">
+            <button class="oa-btn oa-btn-primary" id="oa-save-btn" onclick="oaSave()">
+                <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
+                    <path d="M2 8.5L6 12.5L14 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
+                Save Changes
+            </button>
+        </div>
+    </div>
+
+</div><!-- #oa-root -->
+
+<script>
+/* ── Theme ── */
+(function() {
+    function apply(dark) { document.getElementById('oa-root').classList.toggle('dark', dark); }
+    try {
+        var t = (typeof preferredTheme !== 'undefined' ? preferredTheme : null)
+              || (typeof parent !== 'undefined' ? parent.preferredTheme : null);
+        if (t) { apply(t === 'dark' || t === 'darkTheme'); return; }
+    } catch(e) {}
+    try { ao_module_getSystemThemeColor(function(c) { apply(c !== 'whiteTheme'); }); } catch(e) {}
+})();
+
+/* ── Accordion ── */
+function oaToggle(hdr) {
+    var body = hdr.nextElementSibling;
+    var chev = hdr.querySelector('.oa-chevron');
+    var open = body.classList.toggle('open');
+    chev.classList.toggle('open', open);
+}
+
+function oaToggleEp() {
+    var body = document.getElementById('oa-discovered-body');
+    var chev = document.getElementById('oa-ep-chevron');
+    var open = body.classList.toggle('open');
+    chev.classList.toggle('open', open);
+}
+
+/* ── Callback URL preview ── */
+function oaUpdateCallback() {
+    var base = (document.getElementById('oa-redirecturl').value || '').trim().replace(/\/+$/, '')
+            || window.location.origin;
+    document.getElementById('oa-callback-url').textContent = base + '/system/auth/oauth/authorize';
+}
+
+/* ── OIDC Discovery ── */
+function oaDiscover() {
+    var issuer = document.getElementById('oa-issuer').value.trim();
+    if (!issuer) { oaToast('Please enter an Issuer URL first.', false); return; }
+
+    var btn = document.getElementById('oa-discover-btn');
+    btn.disabled = true;
+    btn.innerHTML = '<span class="oa-spin"></span> Discovering…';
+
+    $.get('../../system/auth/oauth/config/discover', { issuerurl: issuer })
+        .done(function(data) {
+            btn.disabled = false;
+            btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="7" r="4" stroke="currentColor" stroke-width="1.3"/><path d="M10.5 10.5l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> Discover';
+
+            if (data.error) { oaToast('Discovery failed: ' + data.error, false); return; }
+
+            /* Fill discovered endpoint inputs */
+            document.getElementById('oa-auth-ep').value     = data.auth_endpoint     || '';
+            document.getElementById('oa-token-ep').value    = data.token_endpoint    || '';
+            document.getElementById('oa-userinfo-ep').value = data.userinfo_endpoint || '';
+
+            /* Auto-fill manual inputs too (so save works either way) */
+            document.getElementById('oa-auth-ep-manual').value     = data.auth_endpoint     || '';
+            document.getElementById('oa-token-ep-manual').value    = data.token_endpoint    || '';
+            document.getElementById('oa-userinfo-ep-manual').value = data.userinfo_endpoint || '';
 
 
-        $("#idp").change(function() {
-            if ($("#idp").val() == "Gitlab") {
-                $("#server").removeAttr("style");
-            } else {
-                $("#server").attr("style", "display:none;");
+            /* Show the discovered panel, hide manual-only callout */
+            document.getElementById('oa-discovered-wrap').classList.remove('oa-hidden');
+            document.getElementById('oa-discovered-body').classList.add('open');
+            document.getElementById('oa-ep-chevron').classList.add('open');
+            document.getElementById('oa-manual-wrap').classList.add('oa-hidden');
+
+            /* Suggest scope if not already set */
+            var scopeEl = document.getElementById('oa-scope');
+            if (!scopeEl.value && data.scopes_supported && data.scopes_supported.length) {
+                var hasOIDC = data.scopes_supported.indexOf('openid') !== -1;
+                scopeEl.value = hasOIDC
+                    ? ['openid','email','profile'].filter(function(s) {
+                        return data.scopes_supported.indexOf(s) !== -1;
+                      }).join(' ')
+                    : data.scopes_supported.slice(0, 3).join(' ');
             }
             }
-        });
 
 
-        $("#redirecturl").on('input propertychange', function() {
-            if ($("#redirecturl").val() == "") {
-                $("#redirectspan").text(window.location.origin + "/system/auth/oauth/authorize");
-            } else {
-                $("#redirectspan").text($("#redirecturl").val() + "/system/auth/oauth/authorize");
+            /* Suggest username field from supported claims */
+            var ufEl = document.getElementById('oa-usernamefield');
+            if (!ufEl.value && data.claims_supported && data.claims_supported.length) {
+                var preferred = ['email','preferred_username','sub'];
+                for (var i = 0; i < preferred.length; i++) {
+                    if (data.claims_supported.indexOf(preferred[i]) !== -1) {
+                        ufEl.value = preferred[i];
+                        break;
+                    }
+                }
             }
             }
+
+            oaToast('Provider discovered successfully', true);
+        })
+        .fail(function(xhr) {
+            btn.disabled = false;
+            btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="7" r="4" stroke="currentColor" stroke-width="1.3"/><path d="M10.5 10.5l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> Discover';
+            var msg = 'Discovery request failed.';
+            try { var r = JSON.parse(xhr.responseText); if (r.error) msg = r.error; } catch(e) {}
+            oaToast(msg, false);
         });
         });
+}
 
 
-        function read() {
-            $.getJSON("../../system/auth/oauth/config/read", function(data) {
-                if (data.enabled) {
-                    $("#enable").parent().checkbox("check")
-                }
-                if (data.autoredirect) {
-                    $("#autoredirect").parent().checkbox("check")
-                }
-                $("#idp").parent().dropdown("set selected", data.idp);
-                $("#serverurl").val(data.server_url);
-                $("#redirecturl").val(data.redirect_url);
-                $("#clientid").val(data.client_id);
-                $("#clientsecret").val(data.client_secret);
-            });
-        }
+/* ── Load saved config ── */
+function oaRead() {
+    $.getJSON('../../system/auth/oauth/config/read', function(d) {
+        document.getElementById('oa-enable').checked       = !!d.enabled;
+        document.getElementById('oa-autoredirect').checked = !!d.auto_redirect;
+        document.getElementById('oa-issuer').value         = d.issuer_url    || '';
+        document.getElementById('oa-redirecturl').value    = d.redirect_url  || '';
+        document.getElementById('oa-clientid').value       = d.client_id     || '';
+        document.getElementById('oa-clientsecret').value   = d.client_secret || '';
+        document.getElementById('oa-scope').value          = d.scope         || '';
+        document.getElementById('oa-usernamefield').value  = d.username_field || '';
 
 
-        function update() {
-            $.post("../../system/auth/oauth/config/write", {
-                    enabled: $("#enable").parent().checkbox("is checked"),
-                    autoredirect: $("#autoredirect").parent().checkbox("is checked"),
-                    idp: $("#idp").val(),
-                    redirecturl: $("#redirecturl").val(),
-                    clientid: $("#clientid").val(),
-                    clientsecret: $("#clientsecret").val(),
-                    serverurl: $("#serverurl").val()
-                })
-                .done(function(data) {
-                    if (data.error != undefined) {
-                        alert(data.error);
-                    } else {
-                        //OK!
-                        $("#updateSet").stop().finish().slideDown("fast").delay(3000).slideUp('fast');
-                    }
-                });
-        }
+        var hasEp = d.auth_endpoint || d.token_endpoint || d.userinfo_endpoint;
+        if (hasEp) {
+            document.getElementById('oa-auth-ep').value     = d.auth_endpoint     || '';
+            document.getElementById('oa-token-ep').value    = d.token_endpoint    || '';
+            document.getElementById('oa-userinfo-ep').value = d.userinfo_endpoint || '';
 
 
-        function loadIdpList() {
-            $("#idplist").html("");
-            var data = ["Google", "Microsoft", "Github", "Gitlab"];
-            if (data.error !== undefined) {
-                alert(data.error);
-            } else {
-                for (var i = 0; i < data.length; i++) {
-                    let idpinfo = data[i];
-                    $("#idplist").append(`<div class="item" data-value="${idpinfo}">${idpinfo}</div>`);
-                }
-            }
-            $("#idplist").parent().dropdown();
-        }
+            document.getElementById('oa-auth-ep-manual').value     = d.auth_endpoint     || '';
+            document.getElementById('oa-token-ep-manual').value    = d.token_endpoint    || '';
+            document.getElementById('oa-userinfo-ep-manual').value = d.userinfo_endpoint || '';
 
 
-        function initPlaceholder() {
-            $("#redirectspan").text(window.location.origin + "/system/auth/oauth/authorize");
-            $("#redirecturl").attr("placeholder", window.location.origin);
-            $("#serverurl").attr("placeholder", "https://gitlab.com");
+            document.getElementById('oa-discovered-wrap').classList.remove('oa-hidden');
+            document.getElementById('oa-manual-wrap').classList.add('oa-hidden');
         }
         }
-    </script>
-</body>
 
 
-</html>
+        oaUpdateCallback();
+    });
+}
+
+/* ── Resolve effective endpoints ── */
+function oaGetEndpoints() {
+    /* Prefer discovered/reviewed fields; fall back to manual inputs */
+    var wrap = document.getElementById('oa-discovered-wrap');
+    if (!wrap.classList.contains('oa-hidden')) {
+        return {
+            auth:     document.getElementById('oa-auth-ep').value.trim(),
+            token:    document.getElementById('oa-token-ep').value.trim(),
+            userinfo: document.getElementById('oa-userinfo-ep').value.trim()
+        };
+    }
+    return {
+        auth:     document.getElementById('oa-auth-ep-manual').value.trim(),
+        token:    document.getElementById('oa-token-ep-manual').value.trim(),
+        userinfo: document.getElementById('oa-userinfo-ep-manual').value.trim()
+    };
+}
+
+/* ── Save ── */
+function oaSave() {
+    var btn = document.getElementById('oa-save-btn');
+    btn.disabled = true;
+
+    var ep = oaGetEndpoints();
+
+    $.post('../../system/auth/oauth/config/write', {
+        enabled:          document.getElementById('oa-enable').checked      ? 'true' : 'false',
+        autoredirect:     document.getElementById('oa-autoredirect').checked ? 'true' : 'false',
+        issuerurl:        document.getElementById('oa-issuer').value.trim(),
+        clientid:         document.getElementById('oa-clientid').value.trim(),
+        clientsecret:     document.getElementById('oa-clientsecret').value,
+        redirecturl:      document.getElementById('oa-redirecturl').value.trim(),
+        scope:            document.getElementById('oa-scope').value.trim(),
+        usernamefield:    document.getElementById('oa-usernamefield').value.trim(),
+        authendpoint:     ep.auth,
+        tokenendpoint:    ep.token,
+        userinfoendpoint: ep.userinfo
+    }).done(function(data) {
+        btn.disabled = false;
+        if (data.error) { oaToast(data.error, false); }
+        else            { oaToast('Settings saved', true); }
+    }).fail(function() {
+        btn.disabled = false;
+        oaToast('Save request failed. Please try again.', false);
+    });
+}
+
+/* ── Toast helper ── */
+function oaToast(msg, ok) {
+    try {
+        if (typeof parent !== 'undefined' && typeof parent.msgbox === 'function') { parent.msgbox(msg, ok); return; }
+        if (typeof msgbox === 'function') { msgbox(msg, ok); return; }
+    } catch(e) {}
+    alert(msg);
+}
+
+/* ── Init ── */
+$(document).ready(function() {
+    oaRead();
+    document.getElementById('oa-redirecturl').addEventListener('input', oaUpdateCallback);
+});
+</script>
+</body>
+</html>