diff --git a/app/appearance/langs/en_US.json b/app/appearance/langs/en_US.json
index a99ef37dbd7..809491b6db6 100644
--- a/app/appearance/langs/en_US.json
+++ b/app/appearance/langs/en_US.json
@@ -87,7 +87,7 @@
"removeButKeepRelationField": "Remove only this field, keep bidirectional relation field",
"exportPDFLowMemory": "Insufficient available memory to export this PDF, please reduce the content or increase available memory and try exporting again",
"exportConf": "Export settings",
- "exportConfTip": "Account, access authorization code, synchronization, API token and data repo key will not be exported",
+ "exportConfTip": "Account, auth settings, synchronization, API token and data repo key will not be exported",
"importConf": "Import settings",
"importConfTip": "After importing, the current settings will be overwritten and the application will be automatically closed, please restart manually",
"jumpToPage": "Jump to the specified page: 1 ~ ${x}",
@@ -1281,7 +1281,55 @@
"about3": "Please use the Chrome browser and keep it in the same network as the computer, port ${port}(In addition to the random port, the first started workspace will also automatically listen to 6806 as a fixed port, so that it is convenient for the browser to clip extensions or other external programs to call the kernel interface), the addresses that may be connected are as follows: ",
"about4": "Open browser",
"about5": "Access authorization code",
- "about6": "After configuration, it will be used as the access authentication password, leave it blank to close the authentication",
+ "about6": "After configuration, it enables access-code authentication; leave empty to disable this method",
+ "accessAuthCodeDisableWarning": "If you want to disable access-code authentication and only keep OIDC, make sure OIDC is configured first, otherwise you may lose a fallback access method",
+ "accessAuthBypass": "Bypass access authentication",
+ "accessAuthBypassTip": "Skip all access code, OIDC checks and mandatory safety checks (not recommended)",
+ "accessAuthBypassConfirm": "Are you sure you want to bypass all access authentication and security checks? This will allow anyone to access without credentials.",
+ "accessAuthBypassAuthCodeDisabledTip": "Access authorization code is disabled because bypass is enabled.",
+ "accessAuthBypassOIDCDisabledTip": "OIDC settings are disabled because bypass is enabled.",
+ "oidc": "OIDC Login",
+ "oidcTip": "Configure OIDC login authentication",
+ "oidcEnabled": "Enable OIDC",
+ "oidcEnabledTip": "Keep configuration even when disabled",
+ "oidcProvider": "Provider",
+ "oidcProviderTip": "Select a provider or choose Disable to turn off OIDC",
+ "oidcProviderCustom": "Custom OIDC",
+ "oidcProviderConfig": "Provider configuration",
+ "oidcProviderLabel": "Button label",
+ "oidcProviderLabelTip": "Text shown on the login button",
+ "oidcClientID": "Client ID",
+ "oidcClientIDTip": "From your provider application settings",
+ "oidcClientSecret": "Client Secret",
+ "oidcClientSecretTip": "From your provider application settings",
+ "oidcPKCETip": "Use PKCE instead of client secret",
+ "oidcIssuerURL": "Issuer URL",
+ "oidcIssuerURLTip": "Issuer/Discovery URL from the provider",
+ "oidcRedirectURL": "Redirect URL",
+ "oidcRedirectURLTip": "Optional; leave empty to use the local callback",
+ "oidcScopes": "Scopes",
+ "oidcScopesTip": "Separate with commas or spaces",
+ "oidcTenant": "Tenant",
+ "oidcTenantTip": "Tenant ID for Microsoft (optional)",
+ "oidcClaimMap": "Claim map",
+ "oidcClaimMapTip": "Optional: map provider claim fields to SiYuan standard claim fields",
+ "oidcClaimMapInvalid": "Invalid claim map line: ${line}",
+ "oidcClaimMapRowInvalid": "Claim map rule is incomplete",
+ "oidcClaimMapAdd": "Add mapping",
+ "oidcClaimMapValuePlaceholder": "Provider claim key",
+ "oidcFilters": "Access filters",
+ "oidcFiltersTip": "Add rules to restrict which accounts can sign in.",
+ "oidcFiltersTipLine1": "1. Any rule under the same item can match",
+ "oidcFiltersTipLine2": "2. Different items must all match",
+ "oidcFiltersInvalid": "Invalid filter line: ${line}",
+ "oidcFiltersRowInvalid": "Filter rule is incomplete",
+ "oidcFilterAdd": "Add rule",
+ "oidcFilterClaimPlaceholder": "Claim",
+ "oidcFilterPatternPlaceholder": "Pattern",
+ "oidcFilterOpRegex": "Regex",
+ "oidcFilterOpRegexI": "Regex (ignore case)",
+ "oidcFilterOpString": "Equals (ignore case)",
+ "oidcFilterOpExact": "Equals",
"about7": "Follow system lock screen",
"about8": "When enabled, the application will be automatically locked when locking the system screen",
"about11": "Network serving",
@@ -1610,7 +1658,7 @@
"169": "Uploading data repo file %v/%v",
"170": "Uploading data repo chunk %v/%v",
"171": "Uploading data repo reference %s",
- "172": "If you forget the authorization code, please find help here",
+ "172": "If you forget the authorization, please find help here",
"173": "Please enter the access auth code",
"174": "Unlock access",
"175": "Please enter the verification code",
@@ -1713,6 +1761,22 @@
"272": "Unnamed field",
"273": "Do not create the workspace in the partition root path, please create a new folder as the workspace",
"274": "This folder contains other files, please create a new folder as the workspace",
- "275": "Cannot open documents created by a newer version. Please upgrade to the latest version and try again"
+ "275": "Cannot open documents created by a newer version. Please upgrade to the latest version and try again",
+ "276": "OIDC authentication failed, please start login again",
+ "277": "OIDC session is invalid, please start login again",
+ "278": "OIDC authentication was rejected by policy",
+ "279": "In Docker mode, you must enable at least one auth method, or enable access auth bypass",
+ "280": "OIDC is not enabled. Please enable it in Settings and try again",
+ "281": "OIDC provider [%s] failed to initialize. Please check the configuration",
+ "282": "Authentication Successful, you can now close this window and return to the application",
+ "283": "Authentication Failed, please return to SiYuan",
+ "284": "OIDC session has expired, please start login again",
+ "285": "OIDC session already handled, no further action is required",
+ "286": "OIDC callback parameters are missing, please start login again",
+ "287": "OIDC provider mismatch, please check the configuration",
+ "288": "Failed to save OIDC session, please retry or check storage permissions",
+ "289": "OIDC login failed, please retry",
+ "290": "OIDC login timed out, please retry",
+ "291": "Failed to fetch OIDC login status, please make sure your network connection is normal"
}
}
diff --git a/app/appearance/langs/zh_CN.json b/app/appearance/langs/zh_CN.json
index 61bcb8713ed..5db1c266024 100644
--- a/app/appearance/langs/zh_CN.json
+++ b/app/appearance/langs/zh_CN.json
@@ -87,7 +87,7 @@
"removeButKeepRelationField": "仅删除本字段,保留双向关联字段",
"exportPDFLowMemory": "系统可用内存不足,无法导出该 PDF,请减少内容或者增加可用内存后再尝试导出",
"exportConf": "导出设置",
- "exportConfTip": "账号、访问授权码、同步、API token 和数据仓库密钥不会被导出",
+ "exportConfTip": "账号、认证设置、同步、API token 和数据仓库密钥不会被导出",
"importConf": "导入设置",
"importConfTip": "导入后会覆盖当前设置并自动关闭应用,请手动重启",
"jumpToPage": "跳转到指定页:1 ~ ${x}",
@@ -1281,7 +1281,55 @@
"about3": "请使用 Chrome 浏览器并保持和电脑在同一个网络内,端口 ${port}(第一个启动的工作空间除了随机端口外也会自动监听 6806 作为固定端口,以方便浏览器剪藏扩展或者其他外部程序调用内核接口),可能连通的网络地址:",
"about4": "打开浏览器",
"about5": "访问授权码",
- "about6": "配置后作为访问鉴权密码,留空则关闭鉴权",
+ "about6": "配置后开启授权码访问认证方式,留空则关闭该认证方式",
+ "accessAuthCodeDisableWarning": "如需关闭访问授权码方式,仅保留 OIDC 认证,请先确保 OIDC 已配置完成,否则可能失去后备访问方式",
+ "accessAuthBypass": "绕过访问认证",
+ "accessAuthBypassTip": "跳过访问授权码、OIDC 等所有认证和强制安全检查(不推荐)",
+ "accessAuthBypassConfirm": "确定要绕过所有访问认证和安全检查吗?这将允许任何人无需凭据即可访问。",
+ "accessAuthBypassAuthCodeDisabledTip": "已开启绕过访问认证,访问授权码设置已禁用。",
+ "accessAuthBypassOIDCDisabledTip": "已开启绕过访问认证,OIDC 设置已禁用。",
+ "oidc": "OIDC 登录",
+ "oidcTip": "配置 OIDC 登录认证方式",
+ "oidcEnabled": "启用 OIDC",
+ "oidcEnabledTip": "关闭后仍保留配置",
+ "oidcProvider": "提供方",
+ "oidcProviderTip": "选择提供方,选择禁用可关闭 OIDC 登录",
+ "oidcProviderCustom": "自定义 OIDC",
+ "oidcProviderConfig": "提供方配置",
+ "oidcProviderLabel": "按钮名称",
+ "oidcProviderLabelTip": "显示在登录页按钮上的文字",
+ "oidcClientID": "Client ID",
+ "oidcClientIDTip": "提供方应用配置中的 Client ID",
+ "oidcClientSecret": "Client Secret",
+ "oidcClientSecretTip": "提供方应用配置中的 Client Secret",
+ "oidcPKCETip": "启用 PKCE 代替 Client Secret",
+ "oidcIssuerURL": "Issuer URL",
+ "oidcIssuerURLTip": "提供方的 Issuer/Discovery 地址",
+ "oidcRedirectURL": "Redirect URL",
+ "oidcRedirectURLTip": "可选,留空默认回调本地",
+ "oidcScopes": "Scopes",
+ "oidcScopesTip": "可用逗号或空格分隔",
+ "oidcTenant": "Tenant",
+ "oidcTenantTip": "Microsoft 的租户 ID(可选)",
+ "oidcClaimMap": "Claim 映射",
+ "oidcClaimMapTip": "可选,将提供方的 Claim 字段映射到思源内部标准 Claim 字段",
+ "oidcClaimMapInvalid": "Claim 映射格式错误:${line}",
+ "oidcClaimMapRowInvalid": "Claim 映射未填写完整",
+ "oidcClaimMapAdd": "添加映射",
+ "oidcClaimMapValuePlaceholder": "提供方 Claim 字段",
+ "oidcFilters": "访问过滤规则",
+ "oidcFiltersTip": "按需添加规则,限制可登录账户。",
+ "oidcFiltersTipLine1": "1. 同一项多条规则,匹配任意一条即可",
+ "oidcFiltersTipLine2": "2. 不同项需要同时匹配",
+ "oidcFiltersInvalid": "过滤规则格式错误:${line}",
+ "oidcFiltersRowInvalid": "过滤规则未填写完整",
+ "oidcFilterAdd": "添加规则",
+ "oidcFilterClaimPlaceholder": "Claim",
+ "oidcFilterPatternPlaceholder": "规则",
+ "oidcFilterOpRegex": "正则",
+ "oidcFilterOpRegexI": "正则(忽略大小写)",
+ "oidcFilterOpString": "相等(忽略大小写)",
+ "oidcFilterOpExact": "相等",
"about7": "跟随系统锁屏",
"about8": "启用后将会在系统锁屏时自动锁定应用",
"about11": "网络伺服",
@@ -1610,7 +1658,7 @@
"169": "正在上传数据仓库文件 %v/%v",
"170": "正在上传数据仓库分块 %v/%v",
"171": "正在上传数据仓库引用 %s",
- "172": "如果你忘记了授权码,请在这里寻求帮助",
+ "172": "如果你忘记了认证方式,请在这里寻求帮助",
"173": "请输入访问授权码",
"174": "解锁访问",
"175": "请输入验证码",
@@ -1713,6 +1761,22 @@
"272": "未命名字段",
"273": "请勿在分区根路径上创建工作空间,请新建一个文件夹作为工作空间",
"274": "该文件夹包含了其他文件,请新建一个文件夹作为工作空间",
- "275": "无法打开新版本创建的文档,请升级到最新版本后再试"
+ "275": "无法打开新版本创建的文档,请升级到最新版本后再试",
+ "276": "OIDC 认证失败,请重新发起登录",
+ "277": "OIDC 会话无效,请重新发起登录",
+ "278": "OIDC 认证被策略拒绝",
+ "279": "Docker 模式下必须至少启用一种认证方式,或开启绕过访问认证",
+ "280": "OIDC 未启用,请在设置中开启后重试",
+ "281": "OIDC 提供方 [%s] 初始化失败,请检查配置",
+ "282": "认证成功,您现在可以关闭此窗口并返回应用",
+ "283": "认证失败,请返回思源笔记",
+ "284": "OIDC 会话已过期,请重新发起登录",
+ "285": "OIDC 会话已处理,无需重复操作",
+ "286": "OIDC 回调参数缺失,请重新发起登录",
+ "287": "OIDC 提供方不匹配,请检查配置",
+ "288": "OIDC 会话保存失败,请重试或检查存储权限",
+ "289": "OIDC 登录失败,请重试",
+ "290": "OIDC 登录超时,请重试",
+ "291": "OIDC 获取登录状态失败,请确保网络连接正常"
}
}
diff --git a/app/src/config/about.ts b/app/src/config/about.ts
index f876207c489..c9ecb2d598c 100644
--- a/app/src/config/about.ts
+++ b/app/src/config/about.ts
@@ -5,6 +5,7 @@ import {ipcRenderer, shell} from "electron";
import {isBrowser} from "../util/functions";
import {fetchPost} from "../util/fetch";
import {setAccessAuthCode} from "./util/about";
+import {setOIDCConfig} from "./util/oidc";
import {exportLayout} from "../layout/util";
import {exitSiYuan, processSync} from "../dialog/processSystem";
import {isInAndroid, isInHarmony, isInIOS, isIPad, isMac, openByMobile, writeText} from "../protyle/util/compatibility";
@@ -64,26 +65,44 @@ export const about = {
-
-
-
- ${window.siyuan.languages.about5}
-
${window.siyuan.languages.about6}
-
-
-
+
+
+
+ ${window.siyuan.languages.about5}
+
${window.siyuan.languages.about6}
+
${window.siyuan.languages.accessAuthBypassAuthCodeDisabledTip}
-
+
+
+
+
${window.siyuan.languages.about2}
@@ -242,6 +261,35 @@ ${checkUpdateHTML}
if (window.siyuan.config.system.isInsider) {
about.element.querySelector("#isInsider").innerHTML = "
Insider Preview";
}
+ const authCodeElement = about.element.querySelector("#authCode") as HTMLButtonElement;
+ const oidcSettingElement = about.element.querySelector("#oidcSetting") as HTMLButtonElement;
+ const lockScreenModeElement = about.element.querySelector("#lockScreenMode") as HTMLInputElement;
+ const setAuthControlsDisabled = (disabled: boolean) => {
+ authCodeElement?.toggleAttribute("disabled", disabled);
+ oidcSettingElement?.toggleAttribute("disabled", disabled);
+ lockScreenModeElement?.toggleAttribute("disabled", disabled);
+ };
+ const applyAccessAuthBypassChange = (enabled: boolean) => {
+ fetchPost("/api/system/setAccessAuthBypass", {accessAuthBypass: enabled}, () => {
+ window.siyuan.config.accessAuthBypass = enabled;
+ setAuthControlsDisabled(enabled);
+ });
+ };
+ setAuthControlsDisabled(window.siyuan.config.accessAuthBypass);
+ const accessAuthBypassElement = about.element.querySelector("#accessAuthBypass") as HTMLInputElement;
+ if (accessAuthBypassElement) {
+ accessAuthBypassElement.addEventListener("change", () => {
+ if (accessAuthBypassElement.checked) {
+ accessAuthBypassElement.checked = false;
+ confirmDialog("⚠️ " + window.siyuan.languages.accessAuthBypass, window.siyuan.languages.accessAuthBypassConfirm, () => {
+ accessAuthBypassElement.checked = true;
+ applyAccessAuthBypassChange(true);
+ });
+ } else {
+ applyAccessAuthBypassChange(false);
+ }
+ });
+ }
const indexRetentionDaysElement = about.element.querySelector("#indexRetentionDays") as HTMLInputElement;
indexRetentionDaysElement.addEventListener("change", () => {
fetchPost("/api/repo/setRepoIndexRetentionDays", {days: parseInt(indexRetentionDaysElement.value)}, () => {
@@ -303,6 +351,9 @@ ${checkUpdateHTML}
about.element.querySelector("#authCode").addEventListener("click", () => {
setAccessAuthCode();
});
+ about.element.querySelector("#oidcSetting").addEventListener("click", () => {
+ setOIDCConfig();
+ });
const importKeyElement = about.element.querySelector("#importKey");
importKeyElement.addEventListener("click", () => {
const passwordDialog = new Dialog({
@@ -376,8 +427,7 @@ ${checkUpdateHTML}
});
});
});
- const lockScreenModeElement = about.element.querySelector("#lockScreenMode") as HTMLInputElement;
- lockScreenModeElement.addEventListener("change", () => {
+ lockScreenModeElement?.addEventListener("change", () => {
fetchPost("/api/system/setFollowSystemLockScreen", {lockScreenMode: lockScreenModeElement.checked ? 1 : 0}, () => {
window.siyuan.config.system.lockScreenMode = lockScreenModeElement.checked ? 1 : 0;
});
diff --git a/app/src/config/util/about.ts b/app/src/config/util/about.ts
index 65ecc8a628c..cff40e513f3 100644
--- a/app/src/config/util/about.ts
+++ b/app/src/config/util/about.ts
@@ -9,6 +9,7 @@ export const setAccessAuthCode = () => {
content: `
${window.siyuan.languages.about6}
+
${window.siyuan.languages.accessAuthCodeDisableWarning}
diff --git a/app/src/config/util/oidc.ts b/app/src/config/util/oidc.ts
new file mode 100644
index 00000000000..b93f87e1909
--- /dev/null
+++ b/app/src/config/util/oidc.ts
@@ -0,0 +1,639 @@
+import {Dialog} from "../../dialog";
+import {fetchPost} from "../../util/fetch";
+import {isMobile} from "../../util/functions";
+import {showMessage} from "../../dialog/message";
+
+const defaultProviders = ["custom", "google", "microsoft", "github"];
+const claimOptions = [
+ "provider",
+ "subject",
+ "email",
+ "email_verified",
+ "preferred_username",
+ "name",
+ "issuer",
+ "audience",
+ "hosted_domain",
+ "tenant_id",
+ "groups",
+];
+
+const cloneProviders = (providers: Record
| undefined) => {
+ const cloned: Record = {};
+ if (!providers) {
+ return cloned;
+ }
+ Object.keys(providers).forEach((id) => {
+ const provider = providers[id];
+ if (!provider) {
+ return;
+ }
+ cloned[id] = {
+ clientID: provider.clientID || "",
+ clientSecret: provider.clientSecret || "",
+ pkce: !!provider.pkce,
+ redirectURL: provider.redirectURL || "",
+ issuerURL: provider.issuerURL || "",
+ scopes: provider.scopes ? [...provider.scopes] : [],
+ tenant: provider.tenant || "",
+ providerLabel: provider.providerLabel || "",
+ claimMap: provider.claimMap ? Object.assign({}, provider.claimMap) : {},
+ };
+ });
+ return cloned;
+};
+
+const ensureProvider = (providers: Record, id: string) => {
+ if (!providers[id]) {
+ providers[id] = {
+ clientID: "",
+ clientSecret: "",
+ pkce: false,
+ redirectURL: "",
+ issuerURL: "",
+ scopes: [],
+ tenant: "",
+ providerLabel: "",
+ claimMap: {},
+ };
+ }
+};
+
+const parseScopes = (raw: string) => {
+ return raw
+ .split(/[, \t\r\n]+/)
+ .map((item) => item.trim())
+ .filter((item) => item);
+};
+
+type OIDCClaimMapRow = {
+ claim: string;
+ field: string;
+};
+
+const claimMapToRows = (claimMap: Record | undefined) => {
+ const rows: OIDCClaimMapRow[] = [];
+ if (!claimMap) {
+ return rows;
+ }
+ Object.keys(claimMap).sort().forEach((claim) => {
+ rows.push({
+ claim,
+ field: claimMap[claim] || "",
+ });
+ });
+ return rows;
+};
+
+const rowsToClaimMap = (rows: OIDCClaimMapRow[]) => {
+ const claimMap: Record = {};
+ if (!rows.length) {
+ return {claimMap};
+ }
+ for (const row of rows) {
+ const claim = row.claim.trim();
+ const field = row.field.trim();
+ if (!claim || !field) {
+ return {claimMap: null};
+ }
+ claimMap[claim] = field;
+ }
+ return {claimMap};
+};
+
+type OIDCFilterRow = {
+ claim: string;
+ op: string;
+ pattern: string;
+};
+
+const parseFilterPattern = (pattern: string) => {
+ const trimmed = pattern.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const sepIndex = trimmed.indexOf(":");
+ if (sepIndex > 0) {
+ const prefix = trimmed.slice(0, sepIndex).trim().toLowerCase();
+ const rest = trimmed.slice(sepIndex + 1).trim();
+ if (prefix === "regex" || prefix === "re") {
+ return {op: "regex", pattern: rest};
+ }
+ if (prefix === "regexi") {
+ return {op: "regexi", pattern: rest};
+ }
+ if (prefix === "str" || prefix === "string") {
+ return {op: "str", pattern: rest};
+ }
+ if (prefix === "exact") {
+ return {op: "exact", pattern: rest};
+ }
+ }
+ return {op: "regexi", pattern: trimmed};
+};
+
+const filterPatternFromRow = (row: OIDCFilterRow) => {
+ const pattern = row.pattern.trim();
+ if (!pattern) {
+ return "";
+ }
+ switch (row.op) {
+ case "regex":
+ return `regex:${pattern}`;
+ case "regexi":
+ return `regexi:${pattern}`;
+ case "str":
+ return `str:${pattern}`;
+ case "exact":
+ return `exact:${pattern}`;
+ default:
+ return pattern;
+ }
+};
+
+const filtersToRows = (filters: Record | undefined) => {
+ const rows: OIDCFilterRow[] = [];
+ if (!filters) {
+ return rows;
+ }
+ Object.keys(filters).sort().forEach((claim) => {
+ const patterns = filters[claim] || [];
+ patterns.forEach((pattern) => {
+ const parsed = parseFilterPattern(pattern || "");
+ if (!parsed) {
+ return;
+ }
+ rows.push({
+ claim,
+ op: parsed.op,
+ pattern: parsed.pattern,
+ });
+ });
+ });
+ return rows;
+};
+
+const rowsToFilters = (rows: OIDCFilterRow[]) => {
+ const filters: Record = {};
+ for (const row of rows) {
+ const claim = row.claim.trim();
+ const pattern = row.pattern.trim();
+ if (!claim || !pattern) {
+ return {filters: null};
+ }
+ const encoded = filterPatternFromRow(row);
+ if (!encoded) {
+ return {filters: null};
+ }
+ if (!filters[claim]) {
+ filters[claim] = [];
+ }
+ filters[claim].push(encoded);
+ }
+ return {filters};
+};
+
+export const setOIDCConfig = () => {
+ const oidc = window.siyuan.config.oidc || {provider: "", providers: {}, filters: {}};
+ const providers = cloneProviders(oidc.providers);
+ const providerIds = Array.from(new Set([...defaultProviders, ...Object.keys(providers)])).filter((id) => id);
+ if (oidc.provider && !providerIds.includes(oidc.provider)) {
+ providerIds.unshift(oidc.provider);
+ }
+ let enabledProvider = oidc.provider || "";
+ let currentProvider = oidc.provider || providerIds[0];
+ ensureProvider(providers, currentProvider);
+ const providerDisplayNames: Record = {
+ custom: window.siyuan.languages.oidcProviderCustom,
+ google: "Google",
+ microsoft: "Microsoft",
+ github: "GitHub",
+ };
+
+ const dialog = new Dialog({
+ title: "\uD83D\uDD10 " + window.siyuan.languages.oidc,
+ width: isMobile() ? "92vw" : "640px",
+ height: isMobile() ? "80vh" : "70vh",
+ content: `
+
+
+ ${window.siyuan.languages.oidcProvider}
+
${window.siyuan.languages.oidcProviderTip}
+
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcProviderLabel}
+
${window.siyuan.languages.oidcProviderLabelTip}
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcClientID}
+
${window.siyuan.languages.oidcClientIDTip}
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcClientSecret}
+
${window.siyuan.languages.oidcClientSecretTip}
+
+
+
+
+
+
+ PKCE
+
${window.siyuan.languages.oidcPKCETip}
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcIssuerURL}
+
${window.siyuan.languages.oidcIssuerURLTip}
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcRedirectURL}
+
${window.siyuan.languages.oidcRedirectURLTip}
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcScopes}
+
${window.siyuan.languages.oidcScopesTip}
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcTenant}
+
${window.siyuan.languages.oidcTenantTip}
+
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcClaimMap}
+
${window.siyuan.languages.oidcClaimMapTip}
+
+
+
+
+
+
+
+
+
+
+ ${window.siyuan.languages.oidcFilters}
+
${window.siyuan.languages.oidcFiltersTip}
+
${window.siyuan.languages.oidcFiltersTipLine1}
+
${window.siyuan.languages.oidcFiltersTipLine2}
+
+
+
+
+
+
+
+
+
+
+
`,
+ });
+
+ const providerSelect = dialog.element.querySelector("#oidcProvider") as HTMLSelectElement;
+ const providerLabelInput = dialog.element.querySelector("#oidcProviderLabel") as HTMLInputElement;
+ const clientIDInput = dialog.element.querySelector("#oidcClientID") as HTMLInputElement;
+ const clientSecretInput = dialog.element.querySelector("#oidcClientSecret") as HTMLInputElement;
+ const pkceInput = dialog.element.querySelector("#oidcPKCE") as HTMLInputElement;
+ const issuerInput = dialog.element.querySelector("#oidcIssuerURL") as HTMLInputElement;
+ const redirectInput = dialog.element.querySelector("#oidcRedirectURL") as HTMLInputElement;
+ const scopesInput = dialog.element.querySelector("#oidcScopes") as HTMLInputElement;
+ const tenantInput = dialog.element.querySelector("#oidcTenant") as HTMLInputElement;
+ const claimMapList = dialog.element.querySelector("#oidcClaimMapList") as HTMLDivElement;
+ const claimMapAddButton = dialog.element.querySelector("#oidcClaimMapAdd") as HTMLButtonElement;
+ const filtersList = dialog.element.querySelector("#oidcFiltersList") as HTMLDivElement;
+ const filterAddButton = dialog.element.querySelector("#oidcFilterAdd") as HTMLButtonElement;
+ const filtersBlock = dialog.element.querySelector("#oidcFiltersBlock") as HTMLElement;
+ const providerConfigBlock = dialog.element.querySelector("#oidcProviderConfig") as HTMLElement;
+ const issuerRow = dialog.element.querySelector("#oidcIssuerRow") as HTMLElement;
+ const providerLabelRow = dialog.element.querySelector("#oidcProviderLabelRow") as HTMLElement;
+ const scopesRow = dialog.element.querySelector("#oidcScopesRow") as HTMLElement;
+ const tenantRow = dialog.element.querySelector("#oidcTenantRow") as HTMLElement;
+ const pkceRow = dialog.element.querySelector("#oidcPKCERow") as HTMLElement;
+ const claimMapRow = dialog.element.querySelector("#oidcClaimMapRow") as HTMLElement;
+ const buttons = dialog.element.querySelectorAll(".b3-dialog__action .b3-button");
+
+ const setProviderVisibility = (id: string) => {
+ const isKnownProvider = Object.prototype.hasOwnProperty.call(providerDisplayNames, id);
+ const showAll = !isKnownProvider;
+ const showIssuer = showAll || id === "custom";
+ const showTenant = showAll || id === "microsoft";
+ const showPKCE = id === "microsoft";
+ const showClaimMap = showAll || id === "custom";
+ const showScopes = showAll || id === "custom";
+ const showProviderLabel = showAll || id === "custom";
+ issuerRow.classList.toggle("fn__none", !showIssuer);
+ scopesRow.classList.toggle("fn__none", !showScopes);
+ providerLabelRow.classList.toggle("fn__none", !showProviderLabel);
+ tenantRow.classList.toggle("fn__none", !showTenant);
+ pkceRow.classList.toggle("fn__none", !showPKCE);
+ claimMapRow.classList.toggle("fn__none", !showClaimMap);
+ };
+
+ const setProviderConfigVisible = (visible: boolean) => {
+ providerConfigBlock.classList.toggle("fn__none", !visible);
+ };
+
+ const setFiltersVisible = (visible: boolean) => {
+ filtersBlock.classList.toggle("fn__none", !visible);
+ };
+
+ let claimMapRows: OIDCClaimMapRow[] = [];
+
+ const syncPKCEState = (id: string) => {
+ const enablePKCE = id === "microsoft" && pkceInput.checked;
+ if (enablePKCE) {
+ clientSecretInput.value = "";
+ }
+ clientSecretInput.disabled = enablePKCE;
+ };
+
+ const setProviderForm = (id: string) => {
+ const provider = providers[id];
+ providerLabelInput.value = provider.providerLabel || "";
+ clientIDInput.value = provider.clientID || "";
+ clientSecretInput.value = provider.clientSecret || "";
+ pkceInput.checked = id === "microsoft" && !!provider.pkce;
+ issuerInput.value = provider.issuerURL || "";
+ redirectInput.value = provider.redirectURL || "";
+ scopesInput.value = provider.scopes && provider.scopes.length ? provider.scopes.join(", ") : "";
+ tenantInput.value = provider.tenant || "";
+ claimMapRows = claimMapToRows(provider.claimMap);
+ renderClaimMapRows();
+ setProviderVisibility(id);
+ syncPKCEState(id);
+ };
+
+ const readProviderForm = () => {
+ const provider = providers[currentProvider];
+ let claimMap = provider.claimMap || {};
+ if (currentProvider === "custom") {
+ const claimMapResult = rowsToClaimMap(claimMapRows);
+ if (!claimMapResult.claimMap) {
+ showMessage(window.siyuan.languages.oidcClaimMapRowInvalid);
+ return null;
+ }
+ claimMap = claimMapResult.claimMap || {};
+ }
+ return {
+ clientID: clientIDInput.value.trim(),
+ clientSecret: currentProvider === "microsoft" && pkceInput.checked ? "" : clientSecretInput.value,
+ pkce: currentProvider === "microsoft" && pkceInput.checked,
+ redirectURL: redirectInput.value.trim(),
+ issuerURL: issuerInput.value.trim(),
+ scopes: parseScopes(scopesInput.value),
+ tenant: tenantInput.value.trim(),
+ providerLabel: currentProvider === "custom" ? providerLabelInput.value.trim() : "",
+ claimMap,
+ } as Config.IOIDCProviderConf;
+ };
+
+ const filterRows = filtersToRows(oidc.filters);
+ const operatorOptions = [
+ {value: "regexi", label: window.siyuan.languages.oidcFilterOpRegexI},
+ {value: "regex", label: window.siyuan.languages.oidcFilterOpRegex},
+ {value: "str", label: window.siyuan.languages.oidcFilterOpString},
+ {value: "exact", label: window.siyuan.languages.oidcFilterOpExact},
+ ];
+
+ const renderClaimMapRows = () => {
+ const mobile = isMobile();
+ if (!claimMapRows.length) {
+ claimMapList.innerHTML = "";
+ return;
+ }
+
+ claimMapList.innerHTML = ``;
+
+ claimMapList.querySelectorAll("input, select").forEach((input) => {
+ input.addEventListener("change", () => {
+ const li = input.closest("li");
+ if (!li) {
+ return;
+ }
+ const index = parseInt(li.getAttribute("data-index") || "0", 10);
+ const field = (input as HTMLInputElement).dataset.field;
+ if (!claimMapRows[index] || !field) {
+ return;
+ }
+ if (field === "claim") {
+ claimMapRows[index].claim = (input as HTMLInputElement).value;
+ } else if (field === "field") {
+ claimMapRows[index].field = (input as HTMLInputElement).value;
+ }
+ });
+ });
+
+ claimMapList.querySelectorAll('[data-action="remove"]').forEach((remove) => {
+ remove.addEventListener("click", () => {
+ const li = remove.closest("li");
+ if (!li) {
+ return;
+ }
+ const index = parseInt(li.getAttribute("data-index") || "0", 10);
+ claimMapRows.splice(index, 1);
+ renderClaimMapRows();
+ });
+ });
+ };
+
+ const renderFilterRows = () => {
+ filtersList.innerHTML = ``;
+
+ filtersList.querySelectorAll("input, select").forEach((input) => {
+ input.addEventListener("change", () => {
+ const li = input.closest("li");
+ if (!li) {
+ return;
+ }
+ const index = parseInt(li.getAttribute("data-index") || "0", 10);
+ const field = (input as HTMLInputElement).dataset.field;
+ if (!filterRows[index] || !field) {
+ return;
+ }
+ if (field === "op") {
+ filterRows[index].op = (input as HTMLSelectElement).value;
+ } else if (field === "claim") {
+ filterRows[index].claim = (input as HTMLInputElement).value;
+ } else if (field === "pattern") {
+ filterRows[index].pattern = (input as HTMLInputElement).value;
+ }
+ });
+ });
+
+ filtersList.querySelectorAll('[data-action="remove"]').forEach((remove) => {
+ remove.addEventListener("click", () => {
+ const li = remove.closest("li");
+ if (!li) {
+ return;
+ }
+ const index = parseInt(li.getAttribute("data-index") || "0", 10);
+ filterRows.splice(index, 1);
+ renderFilterRows();
+ });
+ });
+ };
+
+ setProviderForm(currentProvider);
+ setProviderConfigVisible(!!enabledProvider);
+ setFiltersVisible(!!enabledProvider);
+ renderFilterRows();
+
+ pkceInput.addEventListener("change", () => {
+ syncPKCEState(currentProvider);
+ });
+
+ claimMapAddButton.addEventListener("click", () => {
+ const defaultClaim = claimOptions[0] || "";
+ claimMapRows.push({
+ claim: defaultClaim,
+ field: "",
+ });
+ renderClaimMapRows();
+ });
+
+ filterAddButton.addEventListener("click", () => {
+ const defaultClaim = claimOptions[0] || "";
+ filterRows.push({
+ claim: defaultClaim,
+ op: "regexi",
+ pattern: "",
+ });
+ renderFilterRows();
+ });
+
+ providerSelect.addEventListener("change", () => {
+ const nextProvider = providerSelect.value;
+ const updated = readProviderForm();
+ if (!updated) {
+ providerSelect.value = enabledProvider;
+ return;
+ }
+ providers[currentProvider] = updated;
+ if (nextProvider) {
+ currentProvider = nextProvider;
+ ensureProvider(providers, currentProvider);
+ setProviderForm(currentProvider);
+ }
+ enabledProvider = nextProvider;
+ setProviderConfigVisible(!!enabledProvider);
+ setFiltersVisible(!!enabledProvider);
+ });
+
+ buttons[0].addEventListener("click", () => {
+ dialog.destroy();
+ });
+
+ buttons[1].addEventListener("click", () => {
+ const updated = readProviderForm();
+ if (!updated) {
+ return;
+ }
+ providers[currentProvider] = updated;
+ const filtersResult = rowsToFilters(filterRows);
+ if (!filtersResult.filters) {
+ showMessage(window.siyuan.languages.oidcFiltersRowInvalid);
+ return;
+ }
+ const payload = {
+ provider: enabledProvider,
+ providers,
+ filters: filtersResult.filters,
+ };
+ fetchPost("/api/system/setOIDCConfig", {oidc: payload}, () => {
+ window.siyuan.config.oidc = payload;
+ dialog.destroy();
+ });
+ });
+};
diff --git a/app/src/mobile/settings/about.ts b/app/src/mobile/settings/about.ts
index 727a56b98bd..aba760d24cd 100644
--- a/app/src/mobile/settings/about.ts
+++ b/app/src/mobile/settings/about.ts
@@ -1,5 +1,6 @@
import {Constants} from "../../constants";
import {setAccessAuthCode} from "../../config/util/about";
+import {setOIDCConfig} from "../../config/util/oidc";
import {Dialog} from "../../dialog";
import {fetchPost} from "../../util/fetch";
import {confirmDialog} from "../../dialog/confirmDialog";
@@ -55,12 +56,30 @@ export const initAbout = () => {
${window.siyuan.languages.about18}
+
+
+ ${window.siyuan.languages.accessAuthBypass}
+
${window.siyuan.languages.accessAuthBypassTip}
+
+
+
+
+
${window.siyuan.languages.about5}
-
${window.siyuan.languages.dataRepoKey}
@@ -206,6 +225,34 @@ export const initAbout = () => {
const workspaceDirElement = modelMainElement.querySelector("#workspaceDir");
genWorkspace(workspaceDirElement);
const importKeyElement = modelMainElement.querySelector("#importKey");
+ const authCodeElement = modelMainElement.querySelector("#authCode") as HTMLButtonElement;
+ const accessAuthBypassElement = modelMainElement.querySelector("#accessAuthBypass") as HTMLInputElement;
+ const oidcSettingElement = modelMainElement.querySelector("#oidcSetting") as HTMLButtonElement;
+ const setAuthControlsDisabled = (disabled: boolean) => {
+ authCodeElement?.toggleAttribute("disabled", disabled);
+ oidcSettingElement?.toggleAttribute("disabled", disabled);
+ };
+ const applyAccessAuthBypassChange = (enabled: boolean) => {
+ fetchPost("/api/system/setAccessAuthBypass", {accessAuthBypass: enabled}, () => {
+ window.siyuan.config.accessAuthBypass = enabled;
+ setAuthControlsDisabled(enabled);
+ });
+ };
+ setAuthControlsDisabled(window.siyuan.config.accessAuthBypass);
+ if (accessAuthBypassElement) {
+ accessAuthBypassElement.addEventListener("change", () => {
+ const nextChecked = accessAuthBypassElement.checked;
+ if (nextChecked) {
+ accessAuthBypassElement.checked = false;
+ confirmDialog("⚠️ " + window.siyuan.languages.accessAuthBypass, window.siyuan.languages.accessAuthBypassConfirm, () => {
+ accessAuthBypassElement.checked = true;
+ applyAccessAuthBypassChange(true);
+ });
+ } else {
+ applyAccessAuthBypassChange(false);
+ }
+ });
+ }
modelMainElement.firstElementChild.addEventListener("click", (event) => {
let target = event.target as HTMLElement;
while (target && (target !== modelMainElement)) {
@@ -214,6 +261,11 @@ export const initAbout = () => {
event.preventDefault();
event.stopPropagation();
break;
+ } else if (target.id === "oidcSetting") {
+ setOIDCConfig();
+ event.preventDefault();
+ event.stopPropagation();
+ break;
} else if (target.id === "importKey") {
const passwordDialog = new Dialog({
title: "🔑 " + window.siyuan.languages.key,
diff --git a/app/src/types/config.d.ts b/app/src/types/config.d.ts
index 962f046821a..c2f6462ad8d 100644
--- a/app/src/types/config.d.ts
+++ b/app/src/types/config.d.ts
@@ -25,6 +25,14 @@ declare namespace Config {
* Access authorization code
*/
accessAuthCode: TAccessAuthCode;
+ /**
+ * Whether to bypass all access authentication checks
+ */
+ accessAuthBypass: boolean;
+ /**
+ * OIDC login configuration
+ */
+ oidc: IOIDC;
account: IAccount;
ai: IAI;
api: IAPI;
@@ -96,6 +104,35 @@ declare namespace Config {
*/
export type TAccessAuthCode = "" | "*******";
+ /**
+ * OIDC login configuration
+ */
+ export interface IOIDC {
+ provider: string;
+ providers: Record
;
+ filters: IOIDCFilter;
+ /**
+ * kernel used fields, frontend ignored
+ *
+ * providerHash?: string;
+ * filterHash?: string;
+ */
+ }
+
+ export interface IOIDCProviderConf {
+ clientID: string;
+ clientSecret: string;
+ pkce: boolean;
+ redirectURL: string;
+ issuerURL: string;
+ scopes: string[];
+ tenant: string;
+ providerLabel: string;
+ claimMap: Record;
+ }
+
+ export type IOIDCFilter = Record;
+
/**
* Account configuration
*/
diff --git a/app/stage/auth.html b/app/stage/auth.html
index 8d5d5094142..4f912777363 100644
--- a/app/stage/auth.html
+++ b/app/stage/auth.html
@@ -73,6 +73,30 @@
box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, .12);
}
+ .b3-button:disabled,
+ .b3-button.fn__disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ box-shadow: none;
+ }
+
+ .b3-button[data-loading="true"]::after {
+ content: "";
+ width: 12px;
+ height: 12px;
+ margin-left: 8px;
+ border: 2px solid rgba(255, 255, 255, 0.6);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: b3-loading-spin 0.8s linear infinite;
+ }
+
+ @keyframes b3-loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
.b3-button--white {
color: rgb(13, 60, 97);
background-color: #d6eaf9;
@@ -173,28 +197,64 @@
font-size: 12px;
margin: 16px 0;
}
+
+ .action-group,
+ .auth-buttons {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .action-group {
+ gap: 10px;
+ margin-top: 8px;
+ }
+
+ .auth-buttons {
+ gap: 6px;
+ width: 100%;
+ }
+
+ .auth-buttons .b3-button {
+ margin: 6px 0;
+ }
{{.workspace}}
+ {{if .hasAccessAuthCode}}
+ {{end}}
-
{{.l1}}
-
- {{.l2}}
-
-
{{.l5}}
-
- {{.l7}}
+
+
+
+
{{.l5}}
+
+ {{.l7}}
+
@@ -417,6 +477,26 @@
{{.workspace
+
+