From 386c078869e8a6c93484f28f9a23e87575da5f48 Mon Sep 17 00:00:00 2001 From: osindex Date: Tue, 7 Jul 2026 21:17:38 +0800 Subject: [PATCH 1/4] feat: add Google OIDC login plugin on external-login seam Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- linapro-oidc-google/Makefile | 7 + linapro-oidc-google/README.md | 82 +++++ linapro-oidc-google/README.zh-CN.md | 82 +++++ .../backend/api/settings/v1/settings.go | 28 ++ .../backend/api/settings/v1/settings_get.go | 17 + .../backend/api/settings/v1/settings_save.go | 26 ++ .../internal/controller/login/login.go | 24 ++ .../controller/login/login_callback.go | 218 ++++++++++++ .../internal/controller/login/login_start.go | 32 ++ .../internal/controller/settings/settings.go | 19 ++ .../controller/settings/settings_v1_get.go | 20 ++ .../controller/settings/settings_v1_save.go | 45 +++ .../backend/internal/service/oauth/oauth.go | 185 ++++++++++ .../internal/service/oauth/oauth_authorize.go | 61 ++++ .../internal/service/oauth/oauth_callback.go | 88 +++++ .../internal/service/oauth/oauth_code.go | 61 ++++ .../internal/service/oauth/oauth_config.go | 68 ++++ .../service/oauth/oauth_return_path.go | 14 + .../internal/service/oauth/oauth_state.go | 115 +++++++ .../internal/service/oauth/oauth_verifier.go | 69 ++++ .../service/oauth/oauth_verifier_http.go | 217 ++++++++++++ .../internal/service/settings/settings.go | 132 +++++++ .../service/settings/settings_code.go | 37 ++ .../service/settings/settings_read.go | 97 ++++++ .../service/settings/settings_save.go | 118 +++++++ linapro-oidc-google/backend/plugin.go | 121 +++++++ .../frontend/pages/settings.vue | 321 ++++++++++++++++++ .../auth.login.after/google-login-entry.vue | 68 ++++ linapro-oidc-google/go.mod | 40 +++ linapro-oidc-google/go.sum | 85 +++++ .../i18n/en-US/apidoc/plugin-api-main.json | 1 + .../manifest/i18n/en-US/error.json | 42 +++ .../manifest/i18n/en-US/plugin.json | 44 +++ .../i18n/zh-CN/apidoc/plugin-api-main.json | 91 +++++ .../manifest/i18n/zh-CN/error.json | 42 +++ .../manifest/i18n/zh-CN/plugin.json | 44 +++ .../sql/001-linapro-oidc-google-settings.sql | 18 + .../001-linapro-oidc-google-settings.sql | 15 + linapro-oidc-google/plugin.yaml | 114 +++++++ linapro-oidc-google/plugin_embed.go | 13 + 40 files changed, 2921 insertions(+) create mode 100644 linapro-oidc-google/Makefile create mode 100644 linapro-oidc-google/README.md create mode 100644 linapro-oidc-google/README.zh-CN.md create mode 100644 linapro-oidc-google/backend/api/settings/v1/settings.go create mode 100644 linapro-oidc-google/backend/api/settings/v1/settings_get.go create mode 100644 linapro-oidc-google/backend/api/settings/v1/settings_save.go create mode 100644 linapro-oidc-google/backend/internal/controller/login/login.go create mode 100644 linapro-oidc-google/backend/internal/controller/login/login_callback.go create mode 100644 linapro-oidc-google/backend/internal/controller/login/login_start.go create mode 100644 linapro-oidc-google/backend/internal/controller/settings/settings.go create mode 100644 linapro-oidc-google/backend/internal/controller/settings/settings_v1_get.go create mode 100644 linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_authorize.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_code.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_config.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_return_path.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_state.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_verifier.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_verifier_http.go create mode 100644 linapro-oidc-google/backend/internal/service/settings/settings.go create mode 100644 linapro-oidc-google/backend/internal/service/settings/settings_code.go create mode 100644 linapro-oidc-google/backend/internal/service/settings/settings_read.go create mode 100644 linapro-oidc-google/backend/internal/service/settings/settings_save.go create mode 100644 linapro-oidc-google/backend/plugin.go create mode 100644 linapro-oidc-google/frontend/pages/settings.vue create mode 100644 linapro-oidc-google/frontend/slots/auth.login.after/google-login-entry.vue create mode 100644 linapro-oidc-google/go.mod create mode 100644 linapro-oidc-google/go.sum create mode 100644 linapro-oidc-google/manifest/i18n/en-US/apidoc/plugin-api-main.json create mode 100644 linapro-oidc-google/manifest/i18n/en-US/error.json create mode 100644 linapro-oidc-google/manifest/i18n/en-US/plugin.json create mode 100644 linapro-oidc-google/manifest/i18n/zh-CN/apidoc/plugin-api-main.json create mode 100644 linapro-oidc-google/manifest/i18n/zh-CN/error.json create mode 100644 linapro-oidc-google/manifest/i18n/zh-CN/plugin.json create mode 100644 linapro-oidc-google/manifest/sql/001-linapro-oidc-google-settings.sql create mode 100644 linapro-oidc-google/manifest/sql/uninstall/001-linapro-oidc-google-settings.sql create mode 100644 linapro-oidc-google/plugin.yaml create mode 100644 linapro-oidc-google/plugin_embed.go diff --git a/linapro-oidc-google/Makefile b/linapro-oidc-google/Makefile new file mode 100644 index 00000000..e27b6e93 --- /dev/null +++ b/linapro-oidc-google/Makefile @@ -0,0 +1,7 @@ +# LinaPro plugin Makefile. +# Shared code generation targets live in the repository root. + +PLUGIN_ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +REPO_ROOT := $(abspath $(PLUGIN_ROOT)/../../..) + +include $(REPO_ROOT)/hack/makefiles/plugin.codegen.mk diff --git a/linapro-oidc-google/README.md b/linapro-oidc-google/README.md new file mode 100644 index 00000000..1d60a015 --- /dev/null +++ b/linapro-oidc-google/README.md @@ -0,0 +1,82 @@ +# linapro-oidc-google + +`linapro-oidc-google` is a reference source plugin for `LinaPro` that demonstrates how to plug a third-party OIDC provider (Google) into the new host external-identity seam. + +English | [简体中文](README.zh-CN.md) + +## What This Sample Demonstrates + +- Declares external-identity provider ownership at plugin init through `plugin.Providers().ProvideExternalIdentity("google")` so the host will only accept `LoginByVerifiedIdentity` calls that name a provider owned by this plugin. +- Registers two browser-facing routes on a plugin-owned portal path group: + - `GET /portal/linapro-oidc-google/login` builds the Google authorize URL, persists an anti-CSRF state cookie, and redirects the browser to Google. + - `GET /portal/linapro-oidc-google/callback` validates the state, exchanges the OAuth code for a verified identity, calls `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(...)`, and redirects the browser back to the SPA login page with the host outcome encoded in the query string. +- Ships one Vue frontend slot component under `frontend/slots/auth.login.after/google-login-entry.vue` that the workspace slot registry auto-mounts below the SPA login form when the plugin is installed and enabled. + +The OAuth code exchange and userinfo fetch are intentionally minimal and clearly stubbed. The reference verifier returns a deterministic verified identity from the incoming authorization code so the surrounding orchestration can be exercised end-to-end without a live Google project. A real deployment must swap `oauthsvc.NewStubIdentityVerifier()` for an HTTP-backed implementation before this plugin can go to production. + +## Directory Layout + +```text +linapro-oidc-google/ + plugin.yaml + plugin_embed.go + go.mod + Makefile + backend/ + plugin.go + internal/ + controller/login/ login-start + callback handlers + service/oauth/ authorize URL construction, state, verifier, callback + frontend/ + slots/auth.login.after/ "Continue with Google" button component + manifest/ + i18n/en-US/ English i18n resources + i18n/zh-CN/ Simplified Chinese i18n resources + README.md + README.zh-CN.md +``` + +## New External-Identity Seam Integration + +The host owns provisioning, tenant candidate resolution, and token minting. The plugin only submits the verified identity DTO and forwards the outcome to the SPA login page. + +| Step | Actor | Action | +| --- | --- | --- | +| 1. User clicks "Continue with Google" | Browser | Full-page navigation to `/portal/linapro-oidc-google/login`. | +| 2. Plugin builds authorize URL | Plugin | `oauthsvc.BuildAuthorizeURL(ctx, ...)` returns a Google authorize URL plus a fresh CSRF state; the plugin sets the state cookie and issues a 302 to Google. | +| 3. Google callback | Browser | Google redirects back to `/portal/linapro-oidc-google/callback?code=...&state=...`. | +| 4. Plugin verifies identity | Plugin | `oauthsvc.CompleteCallback(ctx, ...)` matches the cookie state, exchanges the code for `{subject, email, displayName}`. | +| 5. Host session issuance | Host | `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(ctx, externallogin.LoginInput{...})`. | +| 6. SPA redirect | Plugin | The plugin redirects back to the SPA login page with `accessToken`/`refreshToken` or `preToken`+tenant candidates encoded in the query. | + +Unlinked identities are rejected by the host with `AUTH_EXTERNAL_USER_NOT_PROVISIONED`. The plugin surfaces this to the user through the callback error redirect. + +## Configuration + +`oauth.DefaultConfig()` returns reference values with placeholder client credentials. Real deployments override the config at wiring time so a production Google Cloud project can drive the flow: + +```go +cfg := oauthsvc.DefaultConfig() +cfg.ClientID = os.Getenv("GOOGLE_CLIENT_ID") +cfg.ClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET") +cfg.RedirectURL = "https://your-host/portal/linapro-oidc-google/callback" +``` + +For a production reference wiring, replace the stub verifier with an HTTP-backed one that: + +1. Exchanges the code for an `id_token` at `TokenURL`. +2. Validates the `id_token` signature against Google's JWKS. +3. Fetches the userinfo endpoint if needed for `displayName`. +4. Returns `{Subject, Email, DisplayName}` as `oauthsvc.VerifiedIdentity`. + +## Frontend Slot + +The Vue slot component lives at `frontend/slots/auth.login.after/google-login-entry.vue`. The workspace slot registry auto-discovers it at build time; the runtime registry hides the button when the plugin is not installed or is disabled. The button triggers a full-page navigation to the login-start route so the browser cookie set by the plugin controller is respected during the OIDC handshake. + +## Review Checklist + +- provider ownership is declared through `ProvideExternalIdentity` +- login routes stay on the plugin-owned portal path group, outside the `/x` API namespace +- provisioning, tenant resolution, and token minting stay host-owned +- OAuth stub is documented and easy to replace +- Vue slot component is only rendered when the plugin is installed and enabled diff --git a/linapro-oidc-google/README.zh-CN.md b/linapro-oidc-google/README.zh-CN.md new file mode 100644 index 00000000..b0a8e58e --- /dev/null +++ b/linapro-oidc-google/README.zh-CN.md @@ -0,0 +1,82 @@ +# linapro-oidc-google + +`linapro-oidc-google` 是 `LinaPro` 提供的参考源码插件,用于演示如何把第三方 OIDC(Google)接入到宿主新版外部身份接缝上。 + +[English](README.md) | 简体中文 + +## 示例演示内容 + +- 通过 `plugin.Providers().ProvideExternalIdentity("google")` 在插件初始化阶段声明外部身份 provider 归属,宿主据此拒绝任何声明其他 provider 的 `LoginByVerifiedIdentity` 请求。 +- 在插件私有 portal 路径组下注册两个面向浏览器的公开路由: + - `GET /portal/linapro-oidc-google/login`:构造 Google authorize URL,写入 anti-CSRF 状态 Cookie,并 302 跳转到 Google。 + - `GET /portal/linapro-oidc-google/callback`:校验 state、用授权码换取一个验证过的身份、调用 `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(...)`,最后把宿主返回的 token 或 pre-token 通过 query 参数带回 SPA 登录页。 +- 在 `frontend/slots/auth.login.after/google-login-entry.vue` 提供一个 Vue 插槽组件,前端 slot registry 会在插件安装并启用时自动挂载到登录表单下方。 + +OAuth 的 code 兑换和 userinfo 获取是刻意保留的极简 stub 实现:默认的 verifier 直接根据传入的授权码生成一个稳定的身份 DTO,方便在没有真实 Google 项目的情况下把整个流程串起来演练。真实部署必须将 `oauthsvc.NewStubIdentityVerifier()` 替换为基于 HTTP 的 Google OIDC 实现。 + +## 目录结构 + +```text +linapro-oidc-google/ + plugin.yaml + plugin_embed.go + go.mod + Makefile + backend/ + plugin.go + internal/ + controller/login/ login-start + callback handlers + service/oauth/ authorize URL 构造、state、verifier、callback + frontend/ + slots/auth.login.after/ "使用 Google 账号登录" 按钮组件 + manifest/ + i18n/en-US/ 英文语言包 + i18n/zh-CN/ 简体中文语言包 + README.md + README.zh-CN.md +``` + +## 与新版外部身份接缝的集成 + +宿主负责用户开通、租户候选、Token 发放;插件只提交“已验证”的身份 DTO,并把宿主返回结果带回 SPA。 + +| 步骤 | 参与方 | 动作 | +| --- | --- | --- | +| 1. 用户点击“使用 Google 账号登录” | 浏览器 | 整页跳转到 `/portal/linapro-oidc-google/login`。 | +| 2. 插件构造 authorize URL | 插件 | `oauthsvc.BuildAuthorizeURL(ctx, ...)` 返回 Google authorize URL 和新 state;插件写入 state Cookie 后 302 到 Google。 | +| 3. Google 回调 | 浏览器 | Google 重定向回 `/portal/linapro-oidc-google/callback?code=...&state=...`。 | +| 4. 插件校验身份 | 插件 | `oauthsvc.CompleteCallback(ctx, ...)` 校对 state、用 code 换 `{subject, email, displayName}`。 | +| 5. 宿主发放会话 | 宿主 | `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(ctx, externallogin.LoginInput{...})`。 | +| 6. 回到 SPA | 插件 | 插件把 `accessToken`/`refreshToken` 或 `preToken` + 租户候选拼进 query,再重定向回 SPA 登录页。 | + +宿主对未绑定本地用户的外部身份返回 `AUTH_EXTERNAL_USER_NOT_PROVISIONED`,插件通过回调 error 重定向把该情况透传给前端。 + +## 配置 + +`oauth.DefaultConfig()` 返回带占位客户端凭据的参考配置。真实部署需要在装配阶段覆盖: + +```go +cfg := oauthsvc.DefaultConfig() +cfg.ClientID = os.Getenv("GOOGLE_CLIENT_ID") +cfg.ClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET") +cfg.RedirectURL = "https://your-host/portal/linapro-oidc-google/callback" +``` + +生产实现需要把 stub verifier 换成真正走 HTTP 的实现: + +1. 调用 `TokenURL` 用 code 换 `id_token`。 +2. 用 Google JWKS 校验 `id_token` 签名。 +3. 需要时再调用 userinfo 端点补齐 `displayName`。 +4. 返回 `oauthsvc.VerifiedIdentity{Subject, Email, DisplayName}`。 + +## 前端插槽 + +Vue 插槽组件位于 `frontend/slots/auth.login.after/google-login-entry.vue`。构建期由 slot registry 自动发现;运行期会在插件未安装或已禁用时隐藏按钮。按钮点击时执行整页跳转,以便浏览器保留插件写入的 Cookie。 + +## 审查清单 + +- 已通过 `ProvideExternalIdentity` 声明 provider 归属 +- 登录路由挂在插件私有 portal 路径组,未占用 `/x` API 命名空间 +- 用户开通、租户解析、Token 发放全部保持宿主拥有 +- OAuth stub 有清晰的替换指引 +- Vue 插槽组件仅在插件安装并启用时才被挂载 diff --git a/linapro-oidc-google/backend/api/settings/v1/settings.go b/linapro-oidc-google/backend/api/settings/v1/settings.go new file mode 100644 index 00000000..50d5eed5 --- /dev/null +++ b/linapro-oidc-google/backend/api/settings/v1/settings.go @@ -0,0 +1,28 @@ +// Package v1 declares the linapro-oidc-google settings API DTOs. Settings +// values are persisted through the host sys_config seam and read by the OAuth +// login orchestration at request time. + +package v1 + +// SettingsItem is the settings projection returned by the get endpoint. The +// client secret is always masked; the raw value is never returned by the API. +type SettingsItem struct { + // ClientId is the Google OAuth 2.0 client ID issued by Google Cloud. + ClientId string `json:"clientId" dc:"Google OAuth 2.0 client ID issued by Google Cloud" eg:"1234567890-abc.apps.googleusercontent.com"` + // ClientSecretMasked is the masked client secret indicator. It is never the + // plaintext value: a non-empty stored secret returns a fixed mask, an empty + // stored secret returns an empty string. + ClientSecretMasked string `json:"clientSecretMasked" dc:"Masked client secret indicator; non-empty stored value returns a fixed mask, empty stored value returns empty string. Plaintext secrets are never returned." eg:"************"` + // ClientSecretConfigured reports whether a client secret is currently + // stored, so the UI can distinguish a masked value from an unset value. + ClientSecretConfigured bool `json:"clientSecretConfigured" dc:"True when a client secret is currently stored; used by the UI to distinguish masked from unset" eg:"true"` + // RedirectUrl is the callback URL registered with Google. + RedirectUrl string `json:"redirectUrl" dc:"Fully-qualified callback URL registered with Google; must resolve to the plugin callback route" eg:"https://your-host/portal/linapro-oidc-google/callback"` + // EnableBackendRedirect reports whether SSO token delivery to third-party + // receiver URLs is enabled. + EnableBackendRedirect bool `json:"enableBackendRedirect" dc:"True when SSO token delivery to third-party receiver URLs is enabled" eg:"false"` + // DefaultBackendRedirect is the SPA landing path used after a normal login. + DefaultBackendRedirect string `json:"defaultBackendRedirect" dc:"Workspace landing path used after a normal external login; empty keeps the host default" eg:"/dashboard/analytics"` + // BackendRedirects is the JSON object mapping state keys to receiver URLs. + BackendRedirects string `json:"backendRedirects" dc:"JSON object mapping business state keys to third-party SSO receiver URLs" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` +} diff --git a/linapro-oidc-google/backend/api/settings/v1/settings_get.go b/linapro-oidc-google/backend/api/settings/v1/settings_get.go new file mode 100644 index 00000000..d015b03d --- /dev/null +++ b/linapro-oidc-google/backend/api/settings/v1/settings_get.go @@ -0,0 +1,17 @@ +// settings_get.go declares the get-settings request and response DTOs for the +// linapro-oidc-google settings API. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// GetSettingsReq is the request for querying the linapro-oidc-google settings. +type GetSettingsReq struct { + g.Meta `path:"/plugins/linapro-oidc-google/settings" method:"get" tags:"Google OIDC Login" summary:"Query Google OIDC settings" dc:"Return the persisted linapro-oidc-google settings for the admin settings page. The client secret is always returned as a masked indicator; plaintext secrets are never sent to the client." permission:"linapro-oidc-google:settings:view"` +} + +// GetSettingsRes is the response for querying the linapro-oidc-google settings. +type GetSettingsRes struct { + // Settings carries the projected settings row read from sys_config. + Settings *SettingsItem `json:"settings" dc:"Persisted settings values with client secret masked" eg:"{}"` +} diff --git a/linapro-oidc-google/backend/api/settings/v1/settings_save.go b/linapro-oidc-google/backend/api/settings/v1/settings_save.go new file mode 100644 index 00000000..06d52321 --- /dev/null +++ b/linapro-oidc-google/backend/api/settings/v1/settings_save.go @@ -0,0 +1,26 @@ +// settings_save.go declares the save-settings request and response DTOs for +// the linapro-oidc-google settings API. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// SaveSettingsReq is the request for saving the linapro-oidc-google settings. +type SaveSettingsReq struct { + g.Meta `path:"/plugins/linapro-oidc-google/settings" method:"put" tags:"Google OIDC Login" summary:"Save Google OIDC settings" dc:"Persist the linapro-oidc-google settings to sys_config. An empty or masked client secret keeps the previously stored value; a non-empty non-masked value replaces it." permission:"linapro-oidc-google:settings:update"` + ClientId string `json:"clientId" v:"max-length:256" dc:"Google OAuth 2.0 client ID; empty string clears the stored value" eg:"1234567890-abc.apps.googleusercontent.com"` + ClientSecret string `json:"clientSecret" v:"max-length:512" dc:"Google OAuth 2.0 client secret; empty string or the masked indicator keeps the previously stored value, any other value replaces it" eg:"GOCSPX-secret-value"` + RedirectUrl string `json:"redirectUrl" v:"max-length:512" dc:"Fully-qualified callback URL registered with Google; empty string clears the stored value" eg:"https://your-host/portal/linapro-oidc-google/callback"` + // EnableBackendRedirect toggles SSO token delivery to third-party receivers. + EnableBackendRedirect bool `json:"enableBackendRedirect" dc:"Enable SSO token delivery to third-party receiver URLs matched by state key" eg:"false"` + // DefaultBackendRedirect sets the SPA landing path after a normal login. + DefaultBackendRedirect string `json:"defaultBackendRedirect" v:"max-length:512" dc:"Workspace landing path after a normal external login; empty string keeps the host default" eg:"/dashboard/analytics"` + // BackendRedirects sets the state-key-to-receiver-URL JSON object. + BackendRedirects string `json:"backendRedirects" v:"max-length:4096" dc:"JSON object mapping business state keys to third-party SSO receiver URLs; empty string clears all rules" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` +} + +// SaveSettingsRes is the response for saving the linapro-oidc-google settings. +type SaveSettingsRes struct { + // Settings carries the fresh masked projection after the save applies. + Settings *SettingsItem `json:"settings" dc:"Fresh settings projection after the save applies, with client secret masked" eg:"{}"` +} diff --git a/linapro-oidc-google/backend/internal/controller/login/login.go b/linapro-oidc-google/backend/internal/controller/login/login.go new file mode 100644 index 00000000..0089614d --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/login/login.go @@ -0,0 +1,24 @@ +// Package login implements the HTTP entry points for the Google OIDC login +// flow exposed by the linapro-oidc-google plugin. The controller is +// intentionally thin; all orchestration lives in the oauth service so the +// route handlers only translate between GoFrame request objects and service +// inputs/outputs. +package login + +import ( + oauthsvc "lina-plugin-linapro-oidc-google/backend/internal/service/oauth" + settingssvc "lina-plugin-linapro-oidc-google/backend/internal/service/settings" +) + +// ControllerV1 is the linapro-oidc-google login controller. It handles the +// browser-facing login-start and callback routes registered on the portal +// route group. +type ControllerV1 struct { + oauthSvc oauthsvc.Service // oauthSvc orchestrates the Google OIDC login flow. + settingsSvc settingssvc.Service // settingsSvc supplies SSO delivery rules and the SPA landing path at request time. +} + +// NewV1 creates and returns one Google OIDC login controller instance. +func NewV1(oauthSvc oauthsvc.Service, settingsSvc settingssvc.Service) *ControllerV1 { + return &ControllerV1{oauthSvc: oauthSvc, settingsSvc: settingsSvc} +} diff --git a/linapro-oidc-google/backend/internal/controller/login/login_callback.go b/linapro-oidc-google/backend/internal/controller/login/login_callback.go new file mode 100644 index 00000000..23af998f --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/login/login_callback.go @@ -0,0 +1,218 @@ +// login_callback.go implements the browser-facing GET /callback route that +// consumes the Google callback and hands the verified identity to the host +// external-login seam. + +package login + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gogf/gf/v2/net/ghttp" + + "lina-core/pkg/logger" + + oauthsvc "lina-plugin-linapro-oidc-google/backend/internal/service/oauth" + settingssvc "lina-plugin-linapro-oidc-google/backend/internal/service/settings" +) + +// defaultReturnPath is used when the plugin cannot resolve one from the +// configured login return path. The SPA renders its login page at this path +// in the default LinaPro workspace layout; the outcome query must live inside +// the hash fragment so the SPA hash router exposes it through route.query. +const defaultReturnPath = "/admin/#/auth/login" + +// Callback handles GET /portal/linapro-oidc-google/callback. It forwards the +// callback values to the OAuth service, which validates the self-contained +// signed state (no cookie required), and redirects the browser back to the +// SPA login page with the outcome encoded in the query string. +func (c *ControllerV1) Callback(request *ghttp.Request) { + ctx := request.Context() + callback, err := c.oauthSvc.CompleteCallback(ctx, oauthsvc.CallbackInput{ + Code: request.Get("code").String(), + State: request.Get("state").String(), + }) + returnPath := c.resolveReturnPath() + if err != nil { + logger.Warningf(ctx, "linapro-oidc-google callback failed: %v", err) + redirectURL := buildErrorRedirect(returnPath, err.Error()) + request.Response.RedirectTo(redirectURL, http.StatusFound) + return + } + // The business state key is recovered from the signed state token. + stateKey := strings.TrimSpace(callback.StateKey) + settings := c.loadSettingsSnapshot(ctx) + // SSO token delivery: when enabled and the business state key matches one + // configured rule, the tokens are delivered straight to the third-party + // receiver URL and the browser never visits the SPA login page. + if receiver := resolveSSOReceiver(ctx, settings, stateKey, callback); receiver != "" { + request.Response.RedirectTo(buildReceiverRedirect(receiver, stateKey, callback), http.StatusFound) + return + } + redirectURL := buildSuccessRedirect(returnPath, callback, resolveSPALanding(settings)) + request.Response.RedirectTo(redirectURL, http.StatusFound) +} + +// loadSettingsSnapshot reads the persisted settings, degrading to nil so a +// temporarily unavailable settings store never breaks the login redirect. +func (c *ControllerV1) loadSettingsSnapshot(ctx context.Context) *settingssvc.Snapshot { + if c.settingsSvc == nil { + return nil + } + snapshot, err := c.settingsSvc.Load(ctx) + if err != nil { + logger.Warningf(ctx, "linapro-oidc-google callback settings load failed: %v", err) + return nil + } + return snapshot +} + +// resolveSPALanding returns the configured SPA landing path for normal logins +// or an empty string to keep the host default landing. +func resolveSPALanding(settings *settingssvc.Snapshot) string { + if settings == nil { + return "" + } + return strings.TrimSpace(settings.DefaultBackendRedirect) +} + +// resolveSSOReceiver returns the third-party receiver URL when SSO delivery is +// enabled, the login produced a token pair, and the business state key matches +// one configured rule. Pre-token (multi-tenant) outcomes always fall back to +// the SPA flow because tenant selection requires the workspace UI. +func resolveSSOReceiver(ctx context.Context, settings *settingssvc.Snapshot, stateKey string, callback *oauthsvc.CallbackOutput) string { + if settings == nil || !settings.EnableBackendRedirect || stateKey == "" { + return "" + } + if callback == nil || callback.AccessToken == "" { + return "" + } + rulesJSON := strings.TrimSpace(settings.BackendRedirects) + if rulesJSON == "" { + return "" + } + var rules map[string]string + if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil { + logger.Warningf(ctx, "linapro-oidc-google backend redirect rules malformed: %v", err) + return "" + } + return strings.TrimSpace(rules[stateKey]) +} + +// buildReceiverRedirect appends the token pair and the echoed state key to the +// receiver URL so the third-party system can establish its own session. +func buildReceiverRedirect(receiver string, stateKey string, callback *oauthsvc.CallbackOutput) string { + query := url.Values{} + query.Set("provider", "google") + query.Set("state", stateKey) + query.Set("accessToken", callback.AccessToken) + if callback.RefreshToken != "" { + query.Set("refreshToken", callback.RefreshToken) + } + separator := "?" + if strings.Contains(receiver, "?") { + separator = "&" + } + return receiver + separator + query.Encode() +} + +// resolveReturnPath returns the SPA login return path with a safe default. +// Falling back to a stable SPA path guarantees the browser is never stranded +// on the plugin callback URL. +func (c *ControllerV1) resolveReturnPath() string { + if path := c.oauthSvc.LoginReturnPath(); path != "" { + return path + } + return defaultReturnPath +} + +// buildErrorRedirect renders one SPA redirect URL for a failed callback. +func buildErrorRedirect(returnPath string, message string) string { + query := url.Values{} + query.Set("externalLogin", "1") + query.Set("provider", "google") + query.Set("status", "error") + query.Set("message", message) + return appendQuery(returnPath, query) +} + +// buildSuccessRedirect renders one SPA redirect URL for a successful callback. +// spaLanding optionally overrides the workspace landing path the SPA navigates +// to after storing the tokens. +func buildSuccessRedirect(returnPath string, callback *oauthsvc.CallbackOutput, spaLanding string) string { + query := url.Values{} + query.Set("externalLogin", "1") + query.Set("provider", "google") + if spaLanding != "" { + query.Set("redirect", spaLanding) + } + if callback == nil { + query.Set("status", "error") + query.Set("message", "empty external login response") + return appendQuery(returnPath, query) + } + if callback.PreToken != "" { + query.Set("status", "select-tenant") + query.Set("preToken", callback.PreToken) + query.Set("tenantCount", strconv.Itoa(len(callback.TenantCandidates))) + if encoded := encodeTenantCandidates(callback); encoded != "" { + query.Set("tenants", encoded) + } + return appendQuery(returnPath, query) + } + query.Set("status", "signed-in") + if callback.AccessToken != "" { + query.Set("accessToken", callback.AccessToken) + } + if callback.RefreshToken != "" { + query.Set("refreshToken", callback.RefreshToken) + } + return appendQuery(returnPath, query) +} + +// encodeTenantCandidates serializes the tenant candidates into the compact +// JSON array the SPA login page parses to render the two-stage tenant +// selection after an external login. Encoding failures degrade to an empty +// string so the redirect never breaks over an optional enrichment. +func encodeTenantCandidates(callback *oauthsvc.CallbackOutput) string { + if callback == nil || len(callback.TenantCandidates) == 0 { + return "" + } + type tenantProjection struct { + Id int `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Status string `json:"status"` + } + items := make([]tenantProjection, 0, len(callback.TenantCandidates)) + for _, tenant := range callback.TenantCandidates { + items = append(items, tenantProjection{ + Id: tenant.ID, + Code: tenant.Code, + Name: tenant.Name, + Status: tenant.Status, + }) + } + encoded, err := json.Marshal(items) + if err != nil { + return "" + } + return string(encoded) +} + +// appendQuery attaches encoded query parameters to the given return path +// while preserving any query the path itself already carries. +func appendQuery(returnPath string, query url.Values) string { + if returnPath == "" { + returnPath = defaultReturnPath + } + separator := "?" + if strings.Contains(returnPath, "?") { + separator = "&" + } + return returnPath + separator + query.Encode() +} diff --git a/linapro-oidc-google/backend/internal/controller/login/login_start.go b/linapro-oidc-google/backend/internal/controller/login/login_start.go new file mode 100644 index 00000000..35b818a0 --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/login/login_start.go @@ -0,0 +1,32 @@ +// login_start.go implements the browser-facing GET /login route that +// initiates the Google OIDC authorize redirect. The anti-CSRF state is a +// self-contained HMAC-signed token embedded in the authorize URL, so no +// cookie is required to survive the cross-site round trip through Google. + +package login + +import ( + "net/http" + + "github.com/gogf/gf/v2/net/ghttp" + + "lina-core/pkg/logger" +) + +// Start handles GET /portal/linapro-oidc-google/login. It asks the OAuth +// service for one authorize URL carrying the signed state (with the optional +// ?state= business key embedded) and issues an HTTP 302 redirect to +// Google. On configuration errors it redirects back to the SPA login page +// with an error hint instead of stranding the browser on the plugin route. +func (c *ControllerV1) Start(request *ghttp.Request) { + ctx := request.Context() + stateKey := request.Get("state").String() + authorize, err := c.oauthSvc.BuildAuthorizeURL(ctx, stateKey) + if err != nil { + logger.Warningf(ctx, "linapro-oidc-google authorize URL build failed: %v", err) + redirectURL := buildErrorRedirect(c.resolveReturnPath(), err.Error()) + request.Response.RedirectTo(redirectURL, http.StatusFound) + return + } + request.Response.RedirectTo(authorize.URL, http.StatusFound) +} diff --git a/linapro-oidc-google/backend/internal/controller/settings/settings.go b/linapro-oidc-google/backend/internal/controller/settings/settings.go new file mode 100644 index 00000000..9be81d92 --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/settings/settings.go @@ -0,0 +1,19 @@ +// Package settings implements the linapro-oidc-google settings HTTP +// controller. The controller stays thin: it forwards DTO values to the +// settings service and projects the masked settings result back into the +// response DTO. +package settings + +import ( + settingssvc "lina-plugin-linapro-oidc-google/backend/internal/service/settings" +) + +// ControllerV1 is the linapro-oidc-google settings controller. +type ControllerV1 struct { + settingsSvc settingssvc.Service // settingsSvc persists and projects the plugin settings. +} + +// NewV1 creates and returns a new linapro-oidc-google settings controller instance. +func NewV1(settingsSvc settingssvc.Service) *ControllerV1 { + return &ControllerV1{settingsSvc: settingsSvc} +} diff --git a/linapro-oidc-google/backend/internal/controller/settings/settings_v1_get.go b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_get.go new file mode 100644 index 00000000..ee7c7322 --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_get.go @@ -0,0 +1,20 @@ +// settings_v1_get.go implements the get-settings endpoint of the +// linapro-oidc-google settings controller. + +package settings + +import ( + "context" + + v1 "lina-plugin-linapro-oidc-google/backend/api/settings/v1" +) + +// GetSettings returns the persisted settings projection with the client +// secret masked. +func (c *ControllerV1) GetSettings(ctx context.Context, _ *v1.GetSettingsReq) (res *v1.GetSettingsRes, err error) { + projection, err := c.settingsSvc.Get(ctx) + if err != nil { + return nil, err + } + return &v1.GetSettingsRes{Settings: projectSettingsItem(projection)}, nil +} diff --git a/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go new file mode 100644 index 00000000..03bd6daa --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go @@ -0,0 +1,45 @@ +// settings_v1_save.go implements the save-settings endpoint of the +// linapro-oidc-google settings controller and the shared projection helper. + +package settings + +import ( + "context" + + v1 "lina-plugin-linapro-oidc-google/backend/api/settings/v1" + settingssvc "lina-plugin-linapro-oidc-google/backend/internal/service/settings" +) + +// SaveSettings persists the submitted settings values and returns the fresh +// masked projection. An empty or masked client secret keeps the previously +// stored value. +func (c *ControllerV1) SaveSettings(ctx context.Context, req *v1.SaveSettingsReq) (res *v1.SaveSettingsRes, err error) { + projection, err := c.settingsSvc.Save(ctx, settingssvc.SaveInput{ + ClientID: req.ClientId, + ClientSecret: req.ClientSecret, + RedirectURL: req.RedirectUrl, + EnableBackendRedirect: req.EnableBackendRedirect, + DefaultBackendRedirect: req.DefaultBackendRedirect, + BackendRedirects: req.BackendRedirects, + }) + if err != nil { + return nil, err + } + return &v1.SaveSettingsRes{Settings: projectSettingsItem(projection)}, nil +} + +// projectSettingsItem maps the service projection into the API DTO shape. +func projectSettingsItem(projection *settingssvc.Projection) *v1.SettingsItem { + if projection == nil { + return &v1.SettingsItem{} + } + return &v1.SettingsItem{ + ClientId: projection.ClientID, + ClientSecretMasked: projection.ClientSecretMasked, + ClientSecretConfigured: projection.ClientSecretConfigured, + RedirectUrl: projection.RedirectURL, + EnableBackendRedirect: projection.EnableBackendRedirect, + DefaultBackendRedirect: projection.DefaultBackendRedirect, + BackendRedirects: projection.BackendRedirects, + } +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth.go b/linapro-oidc-google/backend/internal/service/oauth/oauth.go new file mode 100644 index 00000000..589d74c0 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth.go @@ -0,0 +1,185 @@ +// Package oauth implements the Google OIDC login orchestration for the +// linapro-oidc-google reference plugin. It builds the Google authorize URL, +// consumes callback code and state values, exchanges the code for a verified +// identity, and hands the verified identity off to the host external-login +// seam so the host can complete provisioning, tenant selection, and token +// minting under its own governance. +package oauth + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + "lina-core/pkg/plugin/capability/authcap/externallogin" + + settingssvc "lina-plugin-linapro-oidc-google/backend/internal/service/settings" +) + +// Provider is the stable external-identity provider ID owned by this plugin. +// The host validates that external-login requests only pass this provider ID +// when the caller is the linapro-oidc-google plugin. +const Provider = "google" + +// Service defines the Google OIDC login orchestration contract. The service +// separates the authorize URL construction and callback flow so the HTTP +// controller only forwards raw request values and never talks to the host +// external-login seam directly. +type Service interface { + // BuildAuthorizeURL prepares one authorize URL that the browser should + // redirect to when the user clicks the "Continue with Google" button. The + // optional stateKey is embedded into the signed state token so the callback + // can match it against the configured SSO delivery rules without relying + // on browser cookies. + BuildAuthorizeURL(ctx context.Context, stateKey string) (AuthorizeRequest, error) + // CompleteCallback validates the signed callback state, exchanges the OAuth + // code for a verified identity, calls the host external-login seam, and + // returns one login outcome the controller renders as a redirect. + CompleteCallback(ctx context.Context, in CallbackInput) (*CallbackOutput, error) + // LoginReturnPath returns the SPA path the callback controller redirects to + // after the host external-login exchange completes. It reflects the + // configured LoginReturnPath value so wiring stays in one place. + LoginReturnPath() string +} + +// AuthorizeRequest describes the authorize redirect the browser should follow. +type AuthorizeRequest struct { + // URL is the fully-qualified Google authorize URL, including client_id, + // redirect_uri, scope, response_type, and state parameters. + URL string + // State is the self-contained signed state token embedded in the URL. It + // is returned separately for audit logging only; no cookie is required. + State string +} + +// CallbackInput carries the raw values returned by the Google callback. +type CallbackInput struct { + // Code is the OAuth authorization code that the plugin exchanges for a + // verified identity. + Code string + // State is the signed state token Google echoed back. It is validated by + // signature and expiry; no stored copy is needed. + State string +} + +// CallbackOutput carries the host external-login outcome projected into the +// shape the controller needs to build the SPA redirect. +type CallbackOutput struct { + // AccessToken is set when the resolved user has zero or one active tenant. + AccessToken string + // RefreshToken is set together with AccessToken for single-tenant users. + RefreshToken string + // PreToken is set when the resolved user has more than one active tenant + // and the SPA must complete tenant selection through the host auth API. + PreToken string + // TenantCandidates lists the tenants the user may select from when + // PreToken is set. The controller may serialize it into query parameters + // or defer the display to the SPA. + TenantCandidates []externallogin.TenantCandidate + // StateKey is the business state key recovered from the signed state + // token; the controller matches it against the SSO delivery rules. + StateKey string +} + +// Interface compliance assertion for the default OIDC login service implementation. +var _ Service = (*serviceImpl)(nil) + +// serviceImpl orchestrates the Google OIDC login for the reference plugin. +// External-login is the host runtime dependency: the host owns provisioning, +// tenant candidate resolution, and token minting once the plugin submits a +// verified identity. The settings service is re-read on every OAuth request +// so admin edits from the settings page take effect without a restart. +type serviceImpl struct { + externalLoginSvc externallogin.Service // externalLoginSvc completes host session issuance for verified identities. + configResolver *ConfigResolver // configResolver layers persisted settings over static defaults per request. + verifier IdentityVerifier // verifier exchanges an OAuth code for a verified identity. + stateCodec StateCodec // stateCodec signs and validates self-contained state tokens. +} + +// New creates and returns a new Google OIDC login service instance. Each +// dependency is a narrow contract so the reference implementation can wire a +// production HTTP verifier at real deployment time while unit tests inject a +// deterministic verifier. The shared configResolver is also consumed by the +// HTTP identity verifier so both read identical request-time settings. +func New( + externalLoginSvc externallogin.Service, + configResolver *ConfigResolver, + verifier IdentityVerifier, + stateCodec StateCodec, +) Service { + return &serviceImpl{ + externalLoginSvc: externalLoginSvc, + configResolver: configResolver, + verifier: verifier, + stateCodec: stateCodec, + } +} + +// resolveConfig returns the effective request-time configuration through the +// shared resolver, degrading to the zero config when wiring is incomplete. +func (s *serviceImpl) resolveConfig(ctx context.Context) Config { + if s == nil || s.configResolver == nil { + return Config{} + } + return s.configResolver.ResolveConfig(ctx) +} + +// ConfigResolver layers the persisted admin settings on top of the static +// defaults per request so settings edits take effect immediately. It is +// shared between the login orchestration and the HTTP identity verifier so +// both read identical request-time settings. +type ConfigResolver struct { + settingsSvc settingssvc.Service // settingsSvc supplies persisted admin settings; nil keeps static config only. + config Config // config carries the static defaults layered under persisted settings. +} + +// NewConfigResolver creates the shared request-time configuration resolver. +func NewConfigResolver(settingsSvc settingssvc.Service, config Config) *ConfigResolver { + return &ConfigResolver{settingsSvc: settingsSvc, config: config} +} + +// ResolveConfig layers persisted settings over the static defaults; persisted +// empty fields keep the static default, and an unset redirect URL is derived +// from the live request host. +func (r *ConfigResolver) ResolveConfig(ctx context.Context) Config { + if r == nil { + return Config{} + } + resolved := r.config + if r.settingsSvc != nil { + snapshot, err := r.settingsSvc.Load(ctx) + if err == nil && snapshot != nil { + if snapshot.ClientID != "" { + resolved.ClientID = snapshot.ClientID + } + if snapshot.ClientSecret != "" { + resolved.ClientSecret = snapshot.ClientSecret + } + if snapshot.RedirectURL != "" { + resolved.RedirectURL = snapshot.RedirectURL + } + } + // A load failure keeps the static defaults so a temporarily + // unavailable settings store does not take the login entry down; the + // settings service already logs the underlying failure. + } + if resolved.RedirectURL == "" { + resolved.RedirectURL = deriveCallbackURL(ctx) + } + return resolved +} + +// deriveCallbackURL builds the plugin callback URL from the current request +// host so deployments do not need to configure the redirect URL manually. The +// callback path is fixed by the plugin's portal route registration. +func deriveCallbackURL(ctx context.Context) string { + request := g.RequestFromCtx(ctx) + if request == nil || request.Host == "" { + return "" + } + scheme := "http" + if request.TLS != nil || request.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return scheme + "://" + request.Host + "/portal/linapro-oidc-google/callback" +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_authorize.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_authorize.go new file mode 100644 index 00000000..14f8ea3e --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_authorize.go @@ -0,0 +1,61 @@ +// oauth_authorize.go implements the Google authorize URL construction step +// of the Google OIDC login flow. The controller invokes BuildAuthorizeURL and +// then issues an HTTP 302 redirect using the returned URL. The anti-CSRF +// state is a self-contained HMAC-signed token, so no cookie is required to +// survive the round trip through Google. + +package oauth + +import ( + "context" + "net/url" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// BuildAuthorizeURL prepares one Google authorize redirect. The optional +// stateKey is embedded into the signed state token so the callback can match +// it against the configured SSO delivery rules. +func (s *serviceImpl) BuildAuthorizeURL(ctx context.Context, stateKey string) (AuthorizeRequest, error) { + // Re-resolve settings per request so admin edits apply without a restart. + config := s.resolveConfig(ctx) + if strings.TrimSpace(config.ClientID) == "" || strings.TrimSpace(config.RedirectURL) == "" || strings.TrimSpace(config.AuthorizeURL) == "" { + return AuthorizeRequest{}, bizerr.WrapCode( + gerror.New("oidc google: authorize configuration missing"), + CodeConfigMissing, + ) + } + if s.stateCodec == nil { + return AuthorizeRequest{}, bizerr.WrapCode( + gerror.New("oidc google: state codec is missing"), + CodeStateGenerateFailed, + ) + } + state, err := s.stateCodec.Encode(ctx, stateKey, config.ClientSecret) + if err != nil { + return AuthorizeRequest{}, err + } + authorizeURL, err := url.Parse(config.AuthorizeURL) + if err != nil { + return AuthorizeRequest{}, bizerr.WrapCode( + gerror.Wrap(err, "oidc google: authorize URL is not a valid URL"), + CodeConfigMissing, + ) + } + query := authorizeURL.Query() + query.Set("client_id", config.ClientID) + query.Set("redirect_uri", config.RedirectURL) + query.Set("response_type", "code") + query.Set("scope", strings.Join(config.Scopes, " ")) + query.Set("state", state) + query.Set("access_type", "online") + query.Set("prompt", "select_account") + authorizeURL.RawQuery = query.Encode() + return AuthorizeRequest{ + URL: authorizeURL.String(), + State: state, + }, nil +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go new file mode 100644 index 00000000..2bdeb47b --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go @@ -0,0 +1,88 @@ +// oauth_callback.go implements the Google callback step of the OIDC login +// flow. The controller invokes CompleteCallback with the raw callback values +// and forwards the resulting tokens or pre-token to the SPA login page. The +// state parameter is a self-contained HMAC-signed token validated by +// signature and expiry, so the flow does not depend on cookies surviving the +// cross-site round trip through Google. + +package oauth + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" + "lina-core/pkg/plugin/capability/authcap/externallogin" +) + +// CompleteCallback validates the signed callback state, exchanges the code +// for a verified identity, and hands the identity to the host external-login +// seam. Provisioning, tenant resolution, and token minting stay host-owned; +// the plugin only forwards the returned outcome to the controller. +func (s *serviceImpl) CompleteCallback(ctx context.Context, in CallbackInput) (*CallbackOutput, error) { + code := strings.TrimSpace(in.Code) + state := strings.TrimSpace(in.State) + if code == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: callback code is empty"), CodeCallbackCodeRequired) + } + if state == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: callback state is empty"), CodeCallbackStateMismatch) + } + if s.stateCodec == nil { + return nil, bizerr.WrapCode(gerror.New("oidc google: state codec is missing"), CodeCallbackStateMismatch) + } + // Re-resolve settings per request so admin edits apply without a restart. + config := s.resolveConfig(ctx) + statePayload, err := s.stateCodec.Decode(ctx, state, config.ClientSecret) + if err != nil { + return nil, err + } + if s.externalLoginSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc google: external-login service is unavailable"), + CodeExternalLoginUnavailable, + ) + } + identity, err := s.verifier.Verify(ctx, code, config.RedirectURL) + if err != nil { + return nil, err + } + if identity == nil || strings.TrimSpace(identity.Subject) == "" { + return nil, bizerr.WrapCode( + gerror.New("oidc google: verified identity is missing subject"), + CodeIdentityVerifyFailed, + ) + } + logger.Infof( + ctx, + "linapro-oidc-google callback verified provider=%s subject=%s email=%s", + Provider, + identity.Subject, + identity.Email, + ) + loginOut, err := s.externalLoginSvc.LoginByVerifiedIdentity(ctx, externallogin.LoginInput{ + Provider: Provider, + Subject: identity.Subject, + Email: identity.Email, + DisplayName: identity.DisplayName, + }) + if err != nil { + return nil, bizerr.WrapCode(err, CodeExternalLoginFailed) + } + if loginOut == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc google: external-login returned nil outcome"), + CodeExternalLoginFailed, + ) + } + return &CallbackOutput{ + AccessToken: loginOut.AccessToken, + RefreshToken: loginOut.RefreshToken, + PreToken: loginOut.PreToken, + TenantCandidates: loginOut.TenantCandidates, + StateKey: statePayload.StateKey, + }, nil +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go new file mode 100644 index 00000000..9b671af7 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go @@ -0,0 +1,61 @@ +// This file defines linapro-oidc-google business error codes and runtime i18n +// metadata for the Google OIDC login orchestration. + +package oauth + +import ( + "github.com/gogf/gf/v2/errors/gcode" + + "lina-core/pkg/bizerr" +) + +var ( + // CodeConfigMissing reports that the plugin-owned Google client configuration is missing. + CodeConfigMissing = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_CONFIG_MISSING", + "Google OIDC client configuration is missing", + gcode.CodeInvalidConfiguration, + ) + // CodeStateGenerateFailed reports that generating one CSRF state value failed. + CodeStateGenerateFailed = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_STATE_GENERATE_FAILED", + "Failed to generate OIDC state value", + gcode.CodeInternalError, + ) + // CodeCallbackCodeRequired reports that the Google callback did not carry an authorization code. + CodeCallbackCodeRequired = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_CALLBACK_CODE_REQUIRED", + "Authorization code is required", + gcode.CodeInvalidParameter, + ) + // CodeCallbackStateMismatch reports that the callback state value did not match the expected value. + CodeCallbackStateMismatch = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_CALLBACK_STATE_MISMATCH", + "OIDC state value mismatch", + gcode.CodeSecurityReason, + ) + // CodeIdentityVerifyFailed reports that exchanging the code for a verified identity failed. + CodeIdentityVerifyFailed = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_IDENTITY_VERIFY_FAILED", + "Failed to verify Google identity", + gcode.CodeInternalError, + ) + // CodeEmailNotVerified reports that the Google account email is not verified. + CodeEmailNotVerified = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_EMAIL_NOT_VERIFIED", + "Google account email is not verified", + gcode.CodeNotAuthorized, + ) + // CodeExternalLoginUnavailable reports that the host external-login service is missing. + CodeExternalLoginUnavailable = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_EXTERNAL_LOGIN_UNAVAILABLE", + "External-login service is unavailable", + gcode.CodeInvalidOperation, + ) + // CodeExternalLoginFailed reports that the host external-login exchange failed. + CodeExternalLoginFailed = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_EXTERNAL_LOGIN_FAILED", + "Google external login failed", + gcode.CodeInternalError, + ) +) diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_config.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_config.go new file mode 100644 index 00000000..da60be60 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_config.go @@ -0,0 +1,68 @@ +// oauth_config.go defines the plugin-owned Google OIDC client configuration +// used by the reference login flow. The configuration is intentionally kept +// as a plain value struct so it can be sourced from the host plugin config +// section, a plugin manifest file, or a startup override without pulling in +// runtime dependencies here. + +package oauth + +// Config carries the plugin-owned Google OIDC client credentials and the +// OAuth endpoints the plugin talks to. Real deployments load this from the +// plugin configuration source chain; the reference implementation ships with +// placeholder values so the surrounding integration flow can be exercised +// end-to-end without a live Google project. +type Config struct { + // ClientID is the Google OAuth 2.0 client ID issued by Google Cloud. + ClientID string + // ClientSecret is the Google OAuth 2.0 client secret paired with ClientID. + ClientSecret string + // RedirectURL is the callback URL registered with Google. It must resolve + // to the plugin callback route so Google routes the browser back through + // the plugin's own controller. + RedirectURL string + // AuthorizeURL is the Google OAuth authorize endpoint. + AuthorizeURL string + // TokenURL is the Google OAuth token endpoint. + TokenURL string + // UserInfoURL is the Google OIDC userinfo endpoint used to project the + // verified identity into a stable subject, email, and display name. + UserInfoURL string + // Scopes are the OAuth scopes the plugin requests. The reference flow + // requests openid, email, and profile so it can build the verified + // identity DTO the host expects. + Scopes []string + // LoginReturnPath is the SPA path the callback controller redirects to + // after the host external-login exchange completes. The host issues the + // tokens or pre-token that must reach the SPA. + LoginReturnPath string +} + +// defaultConfig returns the reference-implementation configuration. The +// endpoints are the well-known Google OAuth 2.0 URLs; the client credentials +// are intentionally placeholder values that a real deployment must override +// through the plugin configuration source chain. +func defaultConfig() Config { + return Config{ + ClientID: "REPLACE_ME_GOOGLE_CLIENT_ID", + ClientSecret: "REPLACE_ME_GOOGLE_CLIENT_SECRET", + // RedirectURL is intentionally empty: the OAuth service derives the + // callback URL from the live request host unless the admin overrides + // it through the settings page. + RedirectURL: "", + AuthorizeURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo", + Scopes: []string{"openid", "email", "profile"}, + // The default workspace serves the SPA under /admin/ with a hash + // router, so the callback outcome query must live inside the hash + // fragment for vue-router to expose it through route.query. + LoginReturnPath: "/admin/#/auth/login", + } +} + +// DefaultConfig returns one exported copy of the reference-implementation +// configuration so wiring code can start from the reference values and +// override individual fields as needed. +func DefaultConfig() Config { + return defaultConfig() +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_return_path.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_return_path.go new file mode 100644 index 00000000..ccaaf69f --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_return_path.go @@ -0,0 +1,14 @@ +// oauth_return_path.go exposes the configured SPA login return path so the +// callback controller can compose the outbound redirect URL without carrying +// its own copy of the plugin configuration. + +package oauth + +// LoginReturnPath returns the SPA path the callback controller redirects to +// after the host external-login exchange completes. +func (s *serviceImpl) LoginReturnPath() string { + if s == nil || s.configResolver == nil { + return "" + } + return s.configResolver.config.LoginReturnPath +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_state.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_state.go new file mode 100644 index 00000000..0b411113 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_state.go @@ -0,0 +1,115 @@ +// oauth_state.go implements the self-contained HMAC-signed OAuth state used +// by the Google OIDC login flow. The state token embeds the optional business +// state key, a random nonce, and an absolute expiry, and is signed with a key +// derived from the client secret. Because the token is self-validating, the +// flow does not depend on browser cookies surviving the cross-site round trip +// through Google, which removes an entire class of SameSite/proxy cookie +// failures. + +package oauth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// stateTTL bounds the OAuth state lifetime to mitigate replay attacks. +const stateTTL = 10 * time.Minute + +// stateNonceByteSize is the number of random bytes bound to one authorize +// round trip. +const stateNonceByteSize = 16 + +// stateMACKeyPrefix scopes the HMAC key to this plugin so a signature can +// never be replayed against another plugin's flow. +const stateMACKeyPrefix = "linapro-oidc-google::" + +// StatePayload holds the decoded contents of one OAuth state token. +type StatePayload struct { + // StateKey carries the optional business state key used by the SSO + // delivery rules. + StateKey string `json:"stateKey"` + // Nonce is a random value bound to one authorization round trip. + Nonce string `json:"nonce"` + // ExpiresAt is the absolute unix-second deadline beyond which the state + // is rejected. + ExpiresAt int64 `json:"expiresAt"` +} + +// StateCodec encodes and validates self-contained OAuth state tokens. The +// interface keeps the orchestration testable with a deterministic codec. +type StateCodec interface { + // Encode produces one signed state token embedding the business state key. + Encode(ctx context.Context, stateKey string, clientSecret string) (string, error) + // Decode verifies the signature and expiry and returns the payload. + Decode(ctx context.Context, state string, clientSecret string) (StatePayload, error) +} + +// hmacStateCodec is the production HMAC-SHA256 state codec. +type hmacStateCodec struct{} + +// NewHMACStateCodec returns the production self-contained state codec. +func NewHMACStateCodec() StateCodec { + return &hmacStateCodec{} +} + +// Encode serializes and signs one state payload. +func (c *hmacStateCodec) Encode(_ context.Context, stateKey string, clientSecret string) (string, error) { + nonceBuffer := make([]byte, stateNonceByteSize) + if _, err := rand.Read(nonceBuffer); err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc google: state entropy read failed"), CodeStateGenerateFailed) + } + payload := StatePayload{ + StateKey: strings.TrimSpace(stateKey), + Nonce: base64.RawURLEncoding.EncodeToString(nonceBuffer), + ExpiresAt: time.Now().Add(stateTTL).Unix(), + } + raw, err := json.Marshal(payload) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc google: encode state payload failed"), CodeStateGenerateFailed) + } + encoded := base64.RawURLEncoding.EncodeToString(raw) + return encoded + "." + computeStateMAC(encoded, clientSecret), nil +} + +// Decode verifies the HMAC signature and expiry of one state token. +func (c *hmacStateCodec) Decode(_ context.Context, state string, clientSecret string) (StatePayload, error) { + parts := strings.SplitN(strings.TrimSpace(state), ".", 2) + if len(parts) != 2 { + return StatePayload{}, bizerr.WrapCode(gerror.New("oidc google: state token is malformed"), CodeCallbackStateMismatch) + } + expected := computeStateMAC(parts[0], clientSecret) + if !hmac.Equal([]byte(parts[1]), []byte(expected)) { + return StatePayload{}, bizerr.WrapCode(gerror.New("oidc google: state signature mismatch"), CodeCallbackStateMismatch) + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return StatePayload{}, bizerr.WrapCode(gerror.Wrap(err, "oidc google: decode state payload failed"), CodeCallbackStateMismatch) + } + var payload StatePayload + if err = json.Unmarshal(raw, &payload); err != nil { + return StatePayload{}, bizerr.WrapCode(gerror.Wrap(err, "oidc google: parse state payload failed"), CodeCallbackStateMismatch) + } + if payload.ExpiresAt > 0 && time.Now().Unix() > payload.ExpiresAt { + return StatePayload{}, bizerr.WrapCode(gerror.New("oidc google: state has expired"), CodeCallbackStateMismatch) + } + return payload, nil +} + +// computeStateMAC derives one HMAC-SHA256 signature scoped to this plugin. +func computeStateMAC(encodedPayload string, clientSecret string) string { + mac := hmac.New(sha256.New, []byte(stateMACKeyPrefix+clientSecret)) + mac.Write([]byte(encodedPayload)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_verifier.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_verifier.go new file mode 100644 index 00000000..bd99be81 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_verifier.go @@ -0,0 +1,69 @@ +// oauth_verifier.go defines the identity-verifier contract the Google OIDC +// login flow depends on plus a deterministic stub implementation used by unit +// tests. Production wiring uses the HTTP-backed verifier in +// oauth_verifier_http.go, which performs the real token exchange and userinfo +// fetch against Google. + +package oauth + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// VerifiedIdentity is the neutral projection the plugin submits to the host +// external-login seam after a successful Google callback exchange. +type VerifiedIdentity struct { + // Subject is the immutable Google-issued subject identifier ("sub"). + Subject string + // Email is the Google account email captured for audit and hook context. + Email string + // DisplayName is the Google profile display name captured for audit and + // hook context. + DisplayName string +} + +// IdentityVerifier exchanges the OAuth authorization code for a verified +// identity. The reference stub short-circuits the exchange with a deterministic +// verified identity so the surrounding orchestration can be exercised without +// contacting Google. The production verifier performs the token exchange and +// userinfo fetch inside its Verify method. +type IdentityVerifier interface { + // Verify exchanges the supplied authorization code for a verified identity + // obtained from Google. The redirectURL parameter is the same value the + // authorize URL was built with so token exchange can echo it back to + // Google. Returning bizerr.CodeIdentityVerifyFailed maps to a stable client + // error at the controller boundary. + Verify(ctx context.Context, code string, redirectURL string) (*VerifiedIdentity, error) +} + +// stubIdentityVerifier is the deterministic identity verifier used by unit +// tests to exercise the orchestration without contacting Google. Production +// wiring uses NewHTTPIdentityVerifier. +type stubIdentityVerifier struct{} + +// NewStubIdentityVerifier returns the deterministic test verifier that echoes +// the OAuth code as a Google subject. It must never be wired in production; +// plugin.go wires NewHTTPIdentityVerifier instead. +func NewStubIdentityVerifier() IdentityVerifier { + return &stubIdentityVerifier{} +} + +// Verify projects the OAuth code into a deterministic verified identity so +// unit tests can exercise the downstream host external-login wiring without a +// live Google project. It rejects blank codes with a stable business error. +func (v *stubIdentityVerifier) Verify(_ context.Context, code string, _ string) (*VerifiedIdentity, error) { + trimmed := strings.TrimSpace(code) + if trimmed == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: empty authorization code"), CodeIdentityVerifyFailed) + } + return &VerifiedIdentity{ + Subject: "google-stub-subject-" + trimmed, + Email: "stub-user+" + trimmed + "@example.com", + DisplayName: "Google Reference User", + }, nil +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_verifier_http.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_verifier_http.go new file mode 100644 index 00000000..abeebf80 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_verifier_http.go @@ -0,0 +1,217 @@ +// oauth_verifier_http.go implements the production identity verifier: it +// exchanges the OAuth authorization code against Google's token endpoint and +// fetches the verified identity from Google's OpenID Connect userinfo +// endpoint. Outbound requests are bounded by a timeout, error bodies are +// truncated before logging so tokens never leak, and unverified email +// addresses are rejected before the identity reaches the host external-login +// seam. + +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" +) + +// httpTimeout bounds each outbound HTTP request to Google. +const httpTimeout = 15 * time.Second + +// errorBodyLimit caps how much of an error response body is embedded into +// error messages so large payloads and tokens never leak into logs. +const errorBodyLimit = 512 + +// SettingsSource supplies the current OAuth client configuration at request +// time so admin edits apply without a restart. +type SettingsSource interface { + // ResolveConfig returns the effective OAuth configuration for one request. + ResolveConfig(ctx context.Context) Config +} + +// httpIdentityVerifier is the production verifier backed by Google's token +// and userinfo endpoints. +type httpIdentityVerifier struct { + // httpClient performs outbound requests to Google with a bounded timeout. + httpClient *http.Client + // settings supplies client credentials and endpoints per request. + settings SettingsSource +} + +// NewHTTPIdentityVerifier returns the production identity verifier that +// performs the real Google code exchange and userinfo fetch. The settings +// source is consulted on every Verify call so credential rotations apply +// without restarting the host. +func NewHTTPIdentityVerifier(settings SettingsSource) IdentityVerifier { + return &httpIdentityVerifier{ + httpClient: &http.Client{Timeout: httpTimeout}, + settings: settings, + } +} + +// Verify exchanges the authorization code for a Google access token, fetches +// the userinfo claims, enforces email verification, and projects the result +// into the neutral VerifiedIdentity shape. +func (v *httpIdentityVerifier) Verify(ctx context.Context, code string, redirectURL string) (*VerifiedIdentity, error) { + trimmedCode := strings.TrimSpace(code) + if trimmedCode == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: empty authorization code"), CodeIdentityVerifyFailed) + } + if v == nil || v.settings == nil { + return nil, bizerr.WrapCode(gerror.New("oidc google: verifier settings source is missing"), CodeConfigMissing) + } + config := v.settings.ResolveConfig(ctx) + accessToken, err := v.exchangeCode(ctx, config, redirectURL, trimmedCode) + if err != nil { + return nil, err + } + return v.fetchUserIdentity(ctx, config, accessToken) +} + +// exchangeCode trades one authorization code for a Google access token. +func (v *httpIdentityVerifier) exchangeCode(ctx context.Context, config Config, redirectURL string, code string) (string, error) { + clientID := strings.TrimSpace(config.ClientID) + clientSecret := strings.TrimSpace(config.ClientSecret) + if clientID == "" || clientSecret == "" { + return "", bizerr.WrapCode(gerror.New("oidc google: client credentials are not configured"), CodeConfigMissing) + } + resolvedRedirect := strings.TrimSpace(redirectURL) + if resolvedRedirect == "" { + resolvedRedirect = strings.TrimSpace(config.RedirectURL) + } + if resolvedRedirect == "" { + return "", bizerr.WrapCode(gerror.New("oidc google: redirect url is not configured"), CodeConfigMissing) + } + form := url.Values{} + form.Set("code", code) + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + form.Set("redirect_uri", resolvedRedirect) + form.Set("grant_type", "authorization_code") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc google: build token request failed"), CodeIdentityVerifyFailed) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := v.httpClient.Do(req) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc google: call token endpoint failed"), CodeIdentityVerifyFailed) + } + defer closeResponseBody(ctx, resp, "google oauth token endpoint") + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc google: read token response failed"), CodeIdentityVerifyFailed) + } + if resp.StatusCode != http.StatusOK { + return "", bizerr.WrapCode( + gerror.Newf("oidc google: token endpoint returned status %d: %s", resp.StatusCode, truncate(string(body), errorBodyLimit)), + CodeIdentityVerifyFailed, + ) + } + var tokenResp struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err = json.Unmarshal(body, &tokenResp); err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc google: decode token response failed"), CodeIdentityVerifyFailed) + } + if tokenResp.Error != "" { + return "", bizerr.WrapCode( + gerror.Newf("oidc google: token endpoint error: %s: %s", tokenResp.Error, tokenResp.ErrorDesc), + CodeIdentityVerifyFailed, + ) + } + if strings.TrimSpace(tokenResp.AccessToken) == "" { + return "", bizerr.WrapCode(gerror.New("oidc google: token endpoint returned empty access token"), CodeIdentityVerifyFailed) + } + return tokenResp.AccessToken, nil +} + +// fetchUserIdentity retrieves the userinfo claims and enforces the verified +// email requirement before projecting the identity. +func (v *httpIdentityVerifier) fetchUserIdentity(ctx context.Context, config Config, accessToken string) (*VerifiedIdentity, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.UserInfoURL, nil) + if err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc google: build userinfo request failed"), CodeIdentityVerifyFailed) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := v.httpClient.Do(req) + if err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc google: call userinfo endpoint failed"), CodeIdentityVerifyFailed) + } + defer closeResponseBody(ctx, resp, "google oauth userinfo endpoint") + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc google: read userinfo response failed"), CodeIdentityVerifyFailed) + } + if resp.StatusCode != http.StatusOK { + return nil, bizerr.WrapCode( + gerror.Newf("oidc google: userinfo endpoint returned status %d: %s", resp.StatusCode, truncate(string(body), errorBodyLimit)), + CodeIdentityVerifyFailed, + ) + } + var userInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + } + if err = json.Unmarshal(body, &userInfo); err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc google: decode userinfo response failed"), CodeIdentityVerifyFailed) + } + if strings.TrimSpace(userInfo.Sub) == "" || strings.TrimSpace(userInfo.Email) == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: userinfo response missing sub or email"), CodeIdentityVerifyFailed) + } + if !userInfo.EmailVerified { + return nil, bizerr.WrapCode( + gerror.New("oidc google: account email is not verified by Google"), + CodeEmailNotVerified, + ) + } + return &VerifiedIdentity{ + Subject: userInfo.Sub, + Email: userInfo.Email, + DisplayName: userInfo.Name, + }, nil +} + +// SetHTTPClient overrides the outbound HTTP client. Intended for tests that +// need to intercept Google traffic. +func (v *httpIdentityVerifier) SetHTTPClient(client *http.Client) { + if client != nil { + v.httpClient = client + } +} + +// truncate trims long strings used in error messages so secrets and large +// payloads do not leak into logs. +func truncate(value string, limit int) string { + if len(value) <= limit { + return value + } + return fmt.Sprintf("%s...(truncated)", value[:limit]) +} + +// closeResponseBody closes resp.Body once the caller has finished reading it +// and logs any close-time error against the supplied context so error returns +// are never silently discarded. +func closeResponseBody(ctx context.Context, resp *http.Response, endpointLabel string) { + if resp == nil || resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + logger.Warningf(ctx, "%s close response body failed err=%v", endpointLabel, err) + } +} diff --git a/linapro-oidc-google/backend/internal/service/settings/settings.go b/linapro-oidc-google/backend/internal/service/settings/settings.go new file mode 100644 index 00000000..23e6b3d3 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings.go @@ -0,0 +1,132 @@ +// Package settings implements the linapro-oidc-google admin settings service. +// It persists Google OIDC client credentials and the redirect URL through the +// host sys_config seam, and exposes both a masked projection for the admin +// settings page and a raw Snapshot used by the OAuth login orchestration at +// request time. +package settings + +import ( + "context" + + "lina-core/pkg/plugin/capability/hostconfigcap" +) + +// Plugin-scoped sys_config keys owned by the linapro-oidc-google plugin. +// Rows are seeded by the plugin manifest SQL at platform tenant scope +// (`tenant_id = 0`) so the host SysConfig seam can update them in place. +const ( + // ConfigKeyClientID is the sys_config key holding the Google client ID. + ConfigKeyClientID hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.client_id" + // ConfigKeyClientSecret is the sys_config key holding the Google client secret. + ConfigKeyClientSecret hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.client_secret" + // ConfigKeyRedirectURL is the sys_config key holding the Google redirect URL. + ConfigKeyRedirectURL hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.redirect_url" + // ConfigKeyEnableBackendRedirect is the sys_config key holding the SSO + // token-delivery enablement flag ("1" enabled, anything else disabled). + ConfigKeyEnableBackendRedirect hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.enable_backend_redirect" + // ConfigKeyDefaultBackendRedirect is the sys_config key holding the SPA + // landing path used after a normal (non-SSO) external login. + ConfigKeyDefaultBackendRedirect hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.default_backend_redirect" + // ConfigKeyBackendRedirects is the sys_config key holding the JSON object + // that maps business state keys to third-party SSO receiver URLs. + ConfigKeyBackendRedirects hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.backend_redirects" +) + +// SecretMask is the fixed indicator returned to the client whenever a client +// secret is currently stored. It intentionally does not embed characters from +// the real secret so the projection carries no plaintext material. +const SecretMask = "************" + +// Snapshot describes the resolved settings values used by the OAuth login +// flow. Fields hold empty strings when unset; the OAuth service is responsible +// for layering safe defaults on top of unset fields. +type Snapshot struct { + // ClientID is the raw stored Google client ID. + ClientID string + // ClientSecret is the raw stored Google client secret. Callers must never + // project this value to a public API response. + ClientSecret string + // RedirectURL is the raw stored Google redirect URL. + RedirectURL string + // EnableBackendRedirect reports whether SSO token delivery to third-party + // receiver URLs is enabled. + EnableBackendRedirect bool + // DefaultBackendRedirect is the SPA landing path used after a normal + // (non-SSO) external login. Empty keeps the host default landing. + DefaultBackendRedirect string + // BackendRedirects is the raw JSON object mapping business state keys to + // third-party SSO receiver URLs. + BackendRedirects string +} + +// Projection is the masked settings projection returned to the admin settings +// page. It never carries the raw client secret; ClientSecretMasked is either +// SecretMask or an empty string, and ClientSecretConfigured reflects whether a +// non-empty raw secret is currently persisted. +type Projection struct { + // ClientID is the raw stored Google client ID. + ClientID string + // ClientSecretMasked reports SecretMask when a stored secret exists and an + // empty string when no secret is stored yet. + ClientSecretMasked string + // ClientSecretConfigured reports whether a non-empty raw secret is stored. + ClientSecretConfigured bool + // RedirectURL is the raw stored Google redirect URL. + RedirectURL string + // EnableBackendRedirect reports whether SSO token delivery is enabled. + EnableBackendRedirect bool + // DefaultBackendRedirect is the SPA landing path after a normal login. + DefaultBackendRedirect string + // BackendRedirects is the raw JSON object of state key to receiver URL. + BackendRedirects string +} + +// SaveInput carries the caller-supplied settings values submitted through the +// admin settings API. An empty ClientSecret or a value equal to SecretMask +// signals that the previously stored secret must be kept. +type SaveInput struct { + // ClientID replaces the stored client ID. + ClientID string + // ClientSecret replaces the stored client secret unless empty or masked. + ClientSecret string + // RedirectURL replaces the stored redirect URL. + RedirectURL string + // EnableBackendRedirect replaces the stored SSO delivery enablement flag. + EnableBackendRedirect bool + // DefaultBackendRedirect replaces the stored SPA landing path. + DefaultBackendRedirect string + // BackendRedirects replaces the stored state-key-to-receiver JSON object. + // A non-empty value must parse as a JSON object of string values. + BackendRedirects string +} + +// Service defines the linapro-oidc-google settings service surface used by +// both the admin settings HTTP API and the OAuth login orchestration. +type Service interface { + // Get returns the masked projection consumed by the admin settings page. + Get(ctx context.Context) (*Projection, error) + // Save persists the caller-supplied settings values, honoring the + // "empty or masked secret keeps existing" contract. + Save(ctx context.Context, in SaveInput) (*Projection, error) + // Load returns the raw Snapshot consumed by the OAuth login flow. The + // snapshot must be re-read on every OAuth request so admin edits take + // effect without a restart. + Load(ctx context.Context) (*Snapshot, error) +} + +// Interface compliance assertion for the default settings service implementation. +var _ Service = (*serviceImpl)(nil) + +// serviceImpl reads and writes plugin-scoped settings through the host +// sys_config seam. It holds an explicit reference to the seam so wiring stays +// visible and testable. +type serviceImpl struct { + // sysConfigSvc is the host sys_config service used for persisted reads and writes. + sysConfigSvc hostconfigcap.SysConfigService +} + +// New creates and returns a new settings service instance bound to the +// supplied host sys_config service. +func New(sysConfigSvc hostconfigcap.SysConfigService) Service { + return &serviceImpl{sysConfigSvc: sysConfigSvc} +} diff --git a/linapro-oidc-google/backend/internal/service/settings/settings_code.go b/linapro-oidc-google/backend/internal/service/settings/settings_code.go new file mode 100644 index 00000000..56c34eb2 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings_code.go @@ -0,0 +1,37 @@ +// settings_code.go declares linapro-oidc-google settings business error codes. + +package settings + +import ( + "github.com/gogf/gf/v2/errors/gcode" + + "lina-core/pkg/bizerr" +) + +var ( + // CodeStorageUnavailable reports that the host sys_config service is unavailable. + CodeStorageUnavailable = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_SETTINGS_STORAGE_UNAVAILABLE", + "Settings storage service is unavailable", + gcode.CodeInvalidOperation, + ) + // CodeReadFailed reports that reading persisted settings failed. + CodeReadFailed = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_SETTINGS_READ_FAILED", + "Failed to read Google OIDC settings", + gcode.CodeInternalError, + ) + // CodeSaveFailed reports that persisting settings values failed. + CodeSaveFailed = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_SETTINGS_SAVE_FAILED", + "Failed to save Google OIDC settings", + gcode.CodeInternalError, + ) + // CodeRulesInvalid reports a backend-redirect rules payload that is not a + // JSON object of string receiver URLs. + CodeRulesInvalid = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_SETTINGS_RULES_INVALID", + "Backend redirect rules must be a JSON object mapping state keys to receiver URLs", + gcode.CodeInvalidParameter, + ) +) diff --git a/linapro-oidc-google/backend/internal/service/settings/settings_read.go b/linapro-oidc-google/backend/internal/service/settings/settings_read.go new file mode 100644 index 00000000..ca7cc786 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings_read.go @@ -0,0 +1,97 @@ +// settings_read.go implements the read paths of the linapro-oidc-google +// settings service. Reads project stored client secrets into a masked +// indicator so plaintext material never reaches the HTTP boundary. + +package settings + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/plugin/capability/capmodel" + "lina-core/pkg/plugin/capability/hostconfigcap" +) + +// allSettingsKeys returns the ordered list of sys_config keys the plugin owns. +// The order is preserved on batch reads so the projection is deterministic. +func allSettingsKeys() []hostconfigcap.SysConfigKey { + return []hostconfigcap.SysConfigKey{ + ConfigKeyClientID, + ConfigKeyClientSecret, + ConfigKeyRedirectURL, + ConfigKeyEnableBackendRedirect, + ConfigKeyDefaultBackendRedirect, + ConfigKeyBackendRedirects, + } +} + +// Get returns the masked settings projection consumed by the admin settings page. +func (s *serviceImpl) Get(ctx context.Context) (*Projection, error) { + snapshot, err := s.Load(ctx) + if err != nil { + return nil, err + } + return projectFromSnapshot(snapshot), nil +} + +// Load returns the raw settings snapshot consumed by the OAuth login flow. +// Missing sys_config rows are reported through the host BatchGet MissingIDs +// contract and treated as empty values so the OAuth service can layer its +// own defaults on top. +func (s *serviceImpl) Load(ctx context.Context) (*Snapshot, error) { + if s == nil || s.sysConfigSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("settings: host sys_config service is unavailable"), + CodeStorageUnavailable, + ) + } + result, err := s.sysConfigSvc.BatchGet(ctx, allSettingsKeys()) + if err != nil { + if bizerr.Is(err, capmodel.CodeCapabilityUnavailable) { + return nil, bizerr.WrapCode(err, CodeStorageUnavailable) + } + return nil, bizerr.WrapCode(err, CodeReadFailed) + } + snapshot := &Snapshot{} + if result != nil { + if info := result.Items[ConfigKeyClientID]; info != nil { + snapshot.ClientID = info.Value + } + if info := result.Items[ConfigKeyClientSecret]; info != nil { + snapshot.ClientSecret = info.Value + } + if info := result.Items[ConfigKeyRedirectURL]; info != nil { + snapshot.RedirectURL = info.Value + } + if info := result.Items[ConfigKeyEnableBackendRedirect]; info != nil { + snapshot.EnableBackendRedirect = info.Value == enabledFlagValue + } + if info := result.Items[ConfigKeyDefaultBackendRedirect]; info != nil { + snapshot.DefaultBackendRedirect = info.Value + } + if info := result.Items[ConfigKeyBackendRedirects]; info != nil { + snapshot.BackendRedirects = info.Value + } + } + return snapshot, nil +} + +// projectFromSnapshot masks the client secret before returning the projection. +func projectFromSnapshot(snapshot *Snapshot) *Projection { + projection := &Projection{} + if snapshot == nil { + return projection + } + projection.ClientID = snapshot.ClientID + projection.RedirectURL = snapshot.RedirectURL + projection.EnableBackendRedirect = snapshot.EnableBackendRedirect + projection.DefaultBackendRedirect = snapshot.DefaultBackendRedirect + projection.BackendRedirects = snapshot.BackendRedirects + if snapshot.ClientSecret != "" { + projection.ClientSecretConfigured = true + projection.ClientSecretMasked = SecretMask + } + return projection +} diff --git a/linapro-oidc-google/backend/internal/service/settings/settings_save.go b/linapro-oidc-google/backend/internal/service/settings/settings_save.go new file mode 100644 index 00000000..b6ada0db --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings_save.go @@ -0,0 +1,118 @@ +// settings_save.go implements the write path of the linapro-oidc-google +// settings service. An empty or masked client secret keeps the previously +// stored value so admins can freely edit other fields without re-typing the +// secret. + +package settings + +import ( + "context" + "encoding/json" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" + "lina-core/pkg/plugin/capability/capmodel" + "lina-core/pkg/plugin/capability/hostconfigcap" +) + +// enabledFlagValue is the persisted representation of an enabled boolean flag. +const enabledFlagValue = "1" + +// Save persists the caller-supplied settings values through the host +// sys_config seam and returns the fresh masked projection so the caller can +// render the updated form state without a second read. +func (s *serviceImpl) Save(ctx context.Context, in SaveInput) (*Projection, error) { + if s == nil || s.sysConfigSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("settings: host sys_config service is unavailable"), + CodeStorageUnavailable, + ) + } + rules := strings.TrimSpace(in.BackendRedirects) + if err := validateBackendRedirects(rules); err != nil { + return nil, err + } + current, err := s.Load(ctx) + if err != nil { + return nil, err + } + nextSecret := resolveNextSecret(current.ClientSecret, in.ClientSecret) + if err := s.setValue(ctx, ConfigKeyClientID, strings.TrimSpace(in.ClientID)); err != nil { + return nil, err + } + if err := s.setValue(ctx, ConfigKeyRedirectURL, strings.TrimSpace(in.RedirectURL)); err != nil { + return nil, err + } + enableFlag := "" + if in.EnableBackendRedirect { + enableFlag = enabledFlagValue + } + if err := s.setValue(ctx, ConfigKeyEnableBackendRedirect, enableFlag); err != nil { + return nil, err + } + if err := s.setValue(ctx, ConfigKeyDefaultBackendRedirect, strings.TrimSpace(in.DefaultBackendRedirect)); err != nil { + return nil, err + } + if err := s.setValue(ctx, ConfigKeyBackendRedirects, rules); err != nil { + return nil, err + } + if nextSecret != current.ClientSecret { + if err := s.setValue(ctx, ConfigKeyClientSecret, nextSecret); err != nil { + return nil, err + } + } + logger.Infof(ctx, "linapro-oidc-google settings saved clientIdSet=%t redirectUrlSet=%t secretSet=%t ssoEnabled=%t ruleSet=%t", + strings.TrimSpace(in.ClientID) != "", + strings.TrimSpace(in.RedirectURL) != "", + nextSecret != "", + in.EnableBackendRedirect, + rules != "", + ) + return projectFromSnapshot(&Snapshot{ + ClientID: strings.TrimSpace(in.ClientID), + ClientSecret: nextSecret, + RedirectURL: strings.TrimSpace(in.RedirectURL), + EnableBackendRedirect: in.EnableBackendRedirect, + DefaultBackendRedirect: strings.TrimSpace(in.DefaultBackendRedirect), + BackendRedirects: rules, + }), nil +} + +// validateBackendRedirects rejects rule payloads that are not a JSON object of +// string receiver URLs, so a malformed dictionary is surfaced at save time +// instead of silently breaking SSO delivery at login time. +func validateBackendRedirects(rules string) error { + if rules == "" { + return nil + } + var parsed map[string]string + if err := json.Unmarshal([]byte(rules), &parsed); err != nil { + return bizerr.WrapCode(err, CodeRulesInvalid) + } + return nil +} + +// setValue writes one plugin-scoped sys_config value and wraps host errors +// into the plugin's stable business error surface. +func (s *serviceImpl) setValue(ctx context.Context, key hostconfigcap.SysConfigKey, value string) error { + if err := s.sysConfigSvc.SetValue(ctx, key, value); err != nil { + if bizerr.Is(err, capmodel.CodeCapabilityUnavailable) { + return bizerr.WrapCode(err, CodeStorageUnavailable) + } + return bizerr.WrapCode(err, CodeSaveFailed) + } + return nil +} + +// resolveNextSecret honors the "empty or masked secret keeps existing" rule +// so admins can freely edit other fields without re-typing the client secret. +func resolveNextSecret(current string, submitted string) string { + trimmed := strings.TrimSpace(submitted) + if trimmed == "" || trimmed == SecretMask { + return current + } + return trimmed +} diff --git a/linapro-oidc-google/backend/plugin.go b/linapro-oidc-google/backend/plugin.go new file mode 100644 index 00000000..3c5bad03 --- /dev/null +++ b/linapro-oidc-google/backend/plugin.go @@ -0,0 +1,121 @@ +// Package backend wires the linapro-oidc-google source plugin into the host +// plugin registry. It declares the external-identity provider ID this plugin +// owns and registers the browser-facing OIDC login routes on the plugin's +// portal route group. +package backend + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/logger" + "lina-core/pkg/plugin/pluginhost" + pluginoidcgoogle "lina-plugin-linapro-oidc-google" + loginctrl "lina-plugin-linapro-oidc-google/backend/internal/controller/login" + settingsctrl "lina-plugin-linapro-oidc-google/backend/internal/controller/settings" + oauthsvc "lina-plugin-linapro-oidc-google/backend/internal/service/oauth" + settingssvc "lina-plugin-linapro-oidc-google/backend/internal/service/settings" +) + +// pluginID is the immutable identifier declared by the embedded plugin.yaml. +const pluginID = "linapro-oidc-google" + +// portalGroupPath is the browser-facing route group mounted outside the +// reserved /x API namespace. Login-start and callback routes must be public +// so unauthenticated browsers can complete the OIDC handshake. +const portalGroupPath = "/portal/linapro-oidc-google" + +// init registers the linapro-oidc-google source plugin, declares its +// external-identity provider ownership, and registers HTTP routes. +func init() { + plugin := pluginhost.NewDeclarations(pluginID) + plugin.Assets().UseEmbeddedFiles(pluginoidcgoogle.EmbeddedFiles) + // ProvideExternalIdentity establishes provider ownership so the host can + // reject LoginByVerifiedIdentity requests that claim providers this plugin + // does not own. This is an allowed top-level static registration panic. + if err := plugin.Providers().ProvideExternalIdentity(oauthsvc.Provider); err != nil { + panic(err) + } + if err := plugin.HTTP().RegisterRoutes( + pluginhost.ExtensionPointHTTPRouteRegister, + pluginhost.CallbackExecutionModeBlocking, + registerRoutes, + ); err != nil { + panic(err) + } + if err := pluginhost.RegisterSourcePlugin(plugin); err != nil { + panic(err) + } +} + +// registerRoutes registers the plugin HTTP routes: two PUBLIC browser-facing +// OIDC routes on the portal route group (login-start redirects to Google, the +// callback exchanges the code with the host external-login seam), plus the +// PROTECTED admin settings API under the plugin API prefix guarded by +// Auth+Tenancy+Permission middlewares. +func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error { + var ( + routes = registrar.Routes() + middlewares = routes.Middlewares() + services = registrar.Services() + ) + if services == nil { + return gerror.New("linapro-oidc-google routes require host external-login capability") + } + authSvc := services.Auth() + if authSvc == nil || authSvc.ExternalLogin() == nil { + return gerror.New("linapro-oidc-google routes require host external-login sub capability") + } + hostConfigSvc := services.HostConfig() + if hostConfigSvc == nil || hostConfigSvc.SysConfig() == nil { + return gerror.New("linapro-oidc-google routes require host sys_config capability") + } + logger.Infof(ctx, "linapro-oidc-google registering portal routes group=%s", portalGroupPath) + pluginSettingsSvc := settingssvc.New(hostConfigSvc.SysConfig()) + // The shared config resolver feeds both the login orchestration and the + // production HTTP identity verifier so they read identical request-time + // settings (client credentials, endpoints, derived callback URL). + configResolver := oauthsvc.NewConfigResolver(pluginSettingsSvc, oauthsvc.DefaultConfig()) + loginSvc := oauthsvc.New( + authSvc.ExternalLogin(), + configResolver, + oauthsvc.NewHTTPIdentityVerifier(configResolver), + oauthsvc.NewHMACStateCodec(), + ) + loginController := loginctrl.NewV1(loginSvc, pluginSettingsSvc) + settingsController := settingsctrl.NewV1(pluginSettingsSvc) + routes.Group(portalGroupPath, func(group pluginhost.RouteGroup) { + group.Middleware( + middlewares.NeverDoneCtx(), + middlewares.CORS(), + middlewares.RequestBodyLimit(), + middlewares.Ctx(), + ) + group.GET("/login", loginController.Start) + group.GET("/callback", loginController.Callback) + }) + routes.Group(routes.APIPrefix(), func(group pluginhost.RouteGroup) { + group.Group("/api/v1", func(group pluginhost.RouteGroup) { + group.Middleware( + middlewares.NeverDoneCtx(), + middlewares.HandlerResponse(), + middlewares.CORS(), + middlewares.RequestBodyLimit(), + middlewares.Ctx(), + ) + group.Group("/", func(group pluginhost.RouteGroup) { + group.Middleware( + middlewares.Auth(), + middlewares.Tenancy(), + middlewares.Permission(), + ) + group.Bind( + settingsController.GetSettings, + settingsController.SaveSettings, + ) + }) + }) + }) + return routes.Err() +} diff --git a/linapro-oidc-google/frontend/pages/settings.vue b/linapro-oidc-google/frontend/pages/settings.vue new file mode 100644 index 00000000..43b73007 --- /dev/null +++ b/linapro-oidc-google/frontend/pages/settings.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/linapro-oidc-google/frontend/slots/auth.login.after/google-login-entry.vue b/linapro-oidc-google/frontend/slots/auth.login.after/google-login-entry.vue new file mode 100644 index 00000000..3b0b4fba --- /dev/null +++ b/linapro-oidc-google/frontend/slots/auth.login.after/google-login-entry.vue @@ -0,0 +1,68 @@ + + + + + + + diff --git a/linapro-oidc-google/go.mod b/linapro-oidc-google/go.mod new file mode 100644 index 00000000..ecb3ee87 --- /dev/null +++ b/linapro-oidc-google/go.mod @@ -0,0 +1,40 @@ +module lina-plugin-linapro-oidc-google + +go 1.25.0 + +require ( + github.com/gogf/gf/v2 v2.10.1 + lina-core v0.0.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace lina-core => ../../lina-core diff --git a/linapro-oidc-google/go.sum b/linapro-oidc-google/go.sum new file mode 100644 index 00000000..99ffaa59 --- /dev/null +++ b/linapro-oidc-google/go.sum @@ -0,0 +1,85 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 h1:39+jbTenm7KBj4hO2C8ANAxVHpX/7OuRDs1VcGC9ylA= +github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0/go.mod h1:B0s0fVzn0W220E8UTpSGzrrGKsop5KcB90twBeLCiz0= +github.com/gogf/gf/v2 v2.10.1 h1:0Jf6+ij2Xr3QOh/XlZAPVEKlPmui50tfL14WWNAop/Y= +github.com/gogf/gf/v2 v2.10.1/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/linapro-oidc-google/manifest/i18n/en-US/apidoc/plugin-api-main.json b/linapro-oidc-google/manifest/i18n/en-US/apidoc/plugin-api-main.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/en-US/apidoc/plugin-api-main.json @@ -0,0 +1 @@ +{} diff --git a/linapro-oidc-google/manifest/i18n/en-US/error.json b/linapro-oidc-google/manifest/i18n/en-US/error.json new file mode 100644 index 00000000..601176b4 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/en-US/error.json @@ -0,0 +1,42 @@ +{ + "error": { + "plugin": { + "oidc": { + "google": { + "config": { + "missing": "Google OIDC client configuration is missing" + }, + "state": { + "generate": { + "failed": "Failed to generate OIDC state value" + } + }, + "callback": { + "code": { + "required": "Authorization code is required" + }, + "state": { + "mismatch": "OIDC state value mismatch" + } + }, + "identity": { + "verify": { + "failed": "Failed to verify Google identity" + } + }, + "email": { + "not": { + "verified": "Google account email is not verified" + } + }, + "external": { + "login": { + "unavailable": "External-login service is unavailable", + "failed": "Google external login failed" + } + } + } + } + } + } +} diff --git a/linapro-oidc-google/manifest/i18n/en-US/plugin.json b/linapro-oidc-google/manifest/i18n/en-US/plugin.json new file mode 100644 index 00000000..5a0d8fa3 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/en-US/plugin.json @@ -0,0 +1,44 @@ +{ + "plugin": { + "linapro-oidc-google": { + "description": "Reference source plugin that demonstrates Google OIDC third-party login on the framework external-identity seam.", + "name": "Google OIDC Login", + "login": { + "button": "Continue with Google", + "callback": { + "error": "Google login failed. Please try again.", + "unlinked": "This Google account is not linked to any local user. Please contact the administrator." + } + }, + "settings": { + "title": "Google OIDC Settings", + "description": "Configure the Google OAuth 2.0 client used by the login button. The redirect URL must match the callback URL registered in Google Cloud Console.", + "clientIdLabel": "Client ID", + "clientIdPlaceholder": "Enter the Google OAuth 2.0 client ID", + "clientSecretLabel": "Client Secret", + "clientSecretPlaceholder": "Enter the Google OAuth 2.0 client secret", + "clientSecretKeepPlaceholder": "Leave unchanged to keep the stored secret", + "redirectUrlLabel": "Callback URL", + "redirectUrlPlaceholder": "https://your-host/portal/linapro-oidc-google/callback", + "redirectUrlHint": "Fixed by the plugin. Copy this URL into the Authorized redirect URIs of your Google Cloud OAuth client.", + "copyCallbackUrl": "Copy", + "save": "Save", + "saveSuccess": "Google OIDC settings saved.", + "saveFailed": "Failed to save Google OIDC settings.", + "loadFailed": "Failed to load Google OIDC settings.", + "defaultRedirectLabel": "Workspace Landing Path", + "defaultRedirectPlaceholder": "/dashboard/analytics", + "enableSsoLabel": "Enable SSO token delivery", + "enableSsoHint": "When enabled, logins started with a matching state key deliver the tokens straight to the configured third-party receiver URL instead of the workspace.", + "rulesTitle": "SSO Delivery Rules", + "addRule": "Add Rule", + "ruleKeyPlaceholder": "State key", + "ruleUrlPlaceholder": "https://third-party.example.com/sso/receive", + "copyLoginUrl": "Copy Login URL", + "copied": "Login URL copied.", + "copyFailed": "Failed to copy the login URL.", + "deleteRule": "Delete" + } + } + } +} diff --git a/linapro-oidc-google/manifest/i18n/zh-CN/apidoc/plugin-api-main.json b/linapro-oidc-google/manifest/i18n/zh-CN/apidoc/plugin-api-main.json new file mode 100644 index 00000000..514333c8 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/zh-CN/apidoc/plugin-api-main.json @@ -0,0 +1,91 @@ +{ + "plugins": { + "linapro_oidc_google": { + "api": { + "settings": { + "v1": { + "GetSettingsReq": { + "meta": { + "dc": "返回持久化的 linapro-oidc-google 设置供管理设置页使用。客户端密钥始终以掩码指示符返回;明文密钥不会发送给客户端。", + "summary": "查询 Google OIDC 设置", + "tags": "Google OIDC 登录" + }, + "schema": { + "dc": "返回持久化的 linapro-oidc-google 设置供管理设置页使用。客户端密钥始终以掩码指示符返回;明文密钥不会发送给客户端。" + } + }, + "GetSettingsRes": { + "fields": { + "settings": { + "dc": "从 sys_config 读取的设置投影,客户端密钥已掩码" + } + } + }, + "SaveSettingsReq": { + "meta": { + "dc": "将 linapro-oidc-google 设置持久化到 sys_config。空或掩码形式的客户端密钥保留原值;非空非掩码值将替换原值。", + "summary": "保存 Google OIDC 设置", + "tags": "Google OIDC 登录" + }, + "schema": { + "dc": "将 linapro-oidc-google 设置持久化到 sys_config。空或掩码形式的客户端密钥保留原值;非空非掩码值将替换原值。" + }, + "fields": { + "clientId": { + "dc": "Google OAuth 2.0 客户端 ID;空字符串清除已存值" + }, + "clientSecret": { + "dc": "Google OAuth 2.0 客户端密钥;空字符串或掩码指示符保留原值,其他值替换原值" + }, + "redirectUrl": { + "dc": "在 Google 注册的完整回调 URL;空字符串清除已存值" + }, + "enableBackendRedirect": { + "dc": "启用按 state key 匹配的第三方 SSO 令牌投递" + }, + "defaultBackendRedirect": { + "dc": "普通外部登录后的工作台落地页;空字符串保持宿主默认" + }, + "backendRedirects": { + "dc": "业务 state key 到第三方 SSO 接收地址的 JSON 对象;空字符串清除所有规则" + } + } + }, + "SaveSettingsRes": { + "fields": { + "settings": { + "dc": "保存生效后的最新设置投影,客户端密钥已掩码" + } + } + }, + "SettingsItem": { + "fields": { + "clientId": { + "dc": "Google Cloud 签发的 Google OAuth 2.0 客户端 ID" + }, + "clientSecretMasked": { + "dc": "客户端密钥掩码指示符;已存密钥返回固定掩码,未存返回空字符串。明文密钥永不返回。" + }, + "clientSecretConfigured": { + "dc": "当前是否已存储客户端密钥;UI 用它区分掩码值与未设置" + }, + "redirectUrl": { + "dc": "在 Google 注册的完整回调 URL;必须解析到插件回调路由" + }, + "enableBackendRedirect": { + "dc": "是否已启用第三方 SSO 令牌投递" + }, + "defaultBackendRedirect": { + "dc": "普通外部登录后的工作台落地页" + }, + "backendRedirects": { + "dc": "业务 state key 到第三方 SSO 接收地址的 JSON 对象" + } + } + } + } + } + } + } + } +} diff --git a/linapro-oidc-google/manifest/i18n/zh-CN/error.json b/linapro-oidc-google/manifest/i18n/zh-CN/error.json new file mode 100644 index 00000000..04cca15b --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/zh-CN/error.json @@ -0,0 +1,42 @@ +{ + "error": { + "plugin": { + "oidc": { + "google": { + "config": { + "missing": "Google OIDC 客户端配置缺失" + }, + "state": { + "generate": { + "failed": "生成 OIDC 状态值失败" + } + }, + "callback": { + "code": { + "required": "缺少授权码" + }, + "state": { + "mismatch": "OIDC 状态值不匹配" + } + }, + "identity": { + "verify": { + "failed": "校验 Google 身份失败" + } + }, + "email": { + "not": { + "verified": "Google 账号邮箱未通过验证" + } + }, + "external": { + "login": { + "unavailable": "外部登录服务不可用", + "failed": "Google 外部登录失败" + } + } + } + } + } + } +} diff --git a/linapro-oidc-google/manifest/i18n/zh-CN/plugin.json b/linapro-oidc-google/manifest/i18n/zh-CN/plugin.json new file mode 100644 index 00000000..b657a873 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/zh-CN/plugin.json @@ -0,0 +1,44 @@ +{ + "plugin": { + "linapro-oidc-google": { + "description": "演示如何在框架外部身份接缝上接入 Google OIDC 第三方登录的参考源码插件。", + "name": "Google OIDC 登录", + "login": { + "button": "使用 Google 账号登录", + "callback": { + "error": "Google 登录失败,请稍后重试。", + "unlinked": "该 Google 账号尚未绑定本地用户,请联系管理员。" + } + }, + "settings": { + "title": "Google OIDC 设置", + "description": "配置登录按钮使用的 Google OAuth 2.0 客户端。回调 URL 必须与 Google Cloud Console 中注册的回调地址一致。", + "clientIdLabel": "客户端 ID", + "clientIdPlaceholder": "请输入 Google OAuth 2.0 客户端 ID", + "clientSecretLabel": "客户端密钥", + "clientSecretPlaceholder": "请输入 Google OAuth 2.0 客户端密钥", + "clientSecretKeepPlaceholder": "留空则保留已存储的密钥", + "redirectUrlLabel": "回调 URL", + "redirectUrlPlaceholder": "https://your-host/portal/linapro-oidc-google/callback", + "redirectUrlHint": "由插件固定,无需配置。复制此地址到 Google Cloud OAuth 客户端的「已获授权的重定向 URI」中。", + "copyCallbackUrl": "复制", + "save": "保存", + "saveSuccess": "Google OIDC 设置已保存。", + "saveFailed": "保存 Google OIDC 设置失败。", + "loadFailed": "加载 Google OIDC 设置失败。", + "defaultRedirectLabel": "工作台落地页", + "defaultRedirectPlaceholder": "/dashboard/analytics", + "enableSsoLabel": "启用 SSO 令牌投递", + "enableSsoHint": "启用后,携带匹配 state key 发起的登录会把令牌直接投递到配置的第三方接收地址,而不进入工作台。", + "rulesTitle": "SSO 投递规则", + "addRule": "添加规则", + "ruleKeyPlaceholder": "State key", + "ruleUrlPlaceholder": "https://third-party.example.com/sso/receive", + "copyLoginUrl": "复制登录链接", + "copied": "登录链接已复制。", + "copyFailed": "复制登录链接失败。", + "deleteRule": "删除" + } + } + } +} diff --git a/linapro-oidc-google/manifest/sql/001-linapro-oidc-google-settings.sql b/linapro-oidc-google/manifest/sql/001-linapro-oidc-google-settings.sql new file mode 100644 index 00000000..f4ff5d9e --- /dev/null +++ b/linapro-oidc-google/manifest/sql/001-linapro-oidc-google-settings.sql @@ -0,0 +1,18 @@ +-- 001: linapro-oidc-google plugin settings +-- 001:linapro-oidc-google 插件设置 +-- Seeds the platform-level sys_config rows the plugin reads at request time. +-- 为插件在请求期读取的平台级 sys_config 行提供初始化数据。 + +-- ============================================================ +-- Plugin settings seed data: Google OIDC client credentials, redirect URL, +-- and SSO token-delivery configuration +-- 插件设置初始化数据:Google OIDC 客户端凭据、回调地址与 SSO 令牌投递配置 +-- ============================================================ +INSERT INTO sys_config ("tenant_id", "name", "key", "value", "is_builtin", "remark", "created_at", "updated_at") VALUES +(0, 'Google OIDC-Client ID', 'plugin.linapro-oidc-google.client_id', '', 1, 'Google OAuth 2.0 client ID issued by Google Cloud; edit through the Google OIDC settings page.', NOW(), NOW()), +(0, 'Google OIDC-Client Secret', 'plugin.linapro-oidc-google.client_secret', '', 1, 'Google OAuth 2.0 client secret paired with the client ID; the settings page always returns a masked value.', NOW(), NOW()), +(0, 'Google OIDC-Redirect URL', 'plugin.linapro-oidc-google.redirect_url', '', 1, 'Fully-qualified callback URL registered with Google; must resolve to /portal/linapro-oidc-google/callback.', NOW(), NOW()), +(0, 'Google OIDC-Enable SSO Delivery', 'plugin.linapro-oidc-google.enable_backend_redirect', '', 1, 'SSO token-delivery enablement flag; "1" enables delivery to third-party receiver URLs matched by state key.', NOW(), NOW()), +(0, 'Google OIDC-Workspace Landing Path', 'plugin.linapro-oidc-google.default_backend_redirect', '', 1, 'Workspace landing path after a normal external login; empty keeps the host default landing.', NOW(), NOW()), +(0, 'Google OIDC-SSO Delivery Rules', 'plugin.linapro-oidc-google.backend_redirects', '', 1, 'JSON object mapping business state keys to third-party SSO receiver URLs.', NOW(), NOW()) +ON CONFLICT DO NOTHING; diff --git a/linapro-oidc-google/manifest/sql/uninstall/001-linapro-oidc-google-settings.sql b/linapro-oidc-google/manifest/sql/uninstall/001-linapro-oidc-google-settings.sql new file mode 100644 index 00000000..d10c4c18 --- /dev/null +++ b/linapro-oidc-google/manifest/sql/uninstall/001-linapro-oidc-google-settings.sql @@ -0,0 +1,15 @@ +-- 001: linapro-oidc-google plugin settings uninstall +-- 001:linapro-oidc-google 插件设置卸载 +-- Removes the platform-level sys_config rows seeded during install. +-- 删除安装阶段创建的平台级 sys_config 行。 + +DELETE FROM sys_config +WHERE "tenant_id" = 0 + AND "key" IN ( + 'plugin.linapro-oidc-google.client_id', + 'plugin.linapro-oidc-google.client_secret', + 'plugin.linapro-oidc-google.redirect_url', + 'plugin.linapro-oidc-google.enable_backend_redirect', + 'plugin.linapro-oidc-google.default_backend_redirect', + 'plugin.linapro-oidc-google.backend_redirects' + ); diff --git a/linapro-oidc-google/plugin.yaml b/linapro-oidc-google/plugin.yaml new file mode 100644 index 00000000..848037f7 --- /dev/null +++ b/linapro-oidc-google/plugin.yaml @@ -0,0 +1,114 @@ +# Plugin identifier, unique across the host and source plugin directories; use `kebab-case`. +# 插件唯一标识,必须在宿主和源码插件目录中保持唯一,并使用 `kebab-case` 命名风格。 +id: linapro-oidc-google + +# Plugin display name used in plugin management pages, developer docs, and presentation. +# 插件显示名称,用于插件管理页面、开发文档和展示。 +name: Google OIDC Login + +# Plugin semantic version; examples in this repo use the `v` prefix. +# 插件语义化版本号,当前示例统一使用带 `v` 前缀的写法。 +version: v0.1.0 + +# Plugin type enum. Allowed values: `source` is compiled with the host; `dynamic` is loaded as a runtime plugin artifact. +# 插件类型枚举。可选值:`source` 表示随宿主源码编译交付;`dynamic` 表示作为运行时动态插件产物加载。 +type: source + +# Plugin distribution governance enum. `managed` keeps this plugin explicitly governed through plugin management or `plugin.autoEnable`. Use `builtin` only for registered source plugins that host startup must install, enable, and safely upgrade. +# 插件分发治理枚举。`managed`表示本示例仍通过插件管理或`plugin.autoEnable`显式治理。只有需要宿主启动自动安装、启用和安全升级的已注册源码插件才使用`builtin`。 +distribution: managed + +# Multi-tenant scope enum. Allowed values: `platform_only` is platform-context only; `tenant_aware` is governed by tenant context. +# 多租户作用域枚举。可选值:`platform_only` 表示仅平台上下文可见和治理;`tenant_aware` 表示按租户上下文治理。 +scope_nature: platform_only + +# Multi-tenant support flag. `true` means the plugin supports tenant-level installation and provisioning governance. +# 多租户支持标记。`true` 表示插件支持租户级安装与开通治理。 +supports_multi_tenant: false + +# Default install mode enum. Allowed values: `global` enables one shared installation; `tenant_scoped` enables or disables per tenant. +# 默认安装模式枚举。可选值:`global` 表示全局统一安装启用;`tenant_scoped` 表示按租户独立启用或禁用。 +default_install_mode: global + +# Plugin description summarizing the capability boundary. +# 插件说明,概述插件的能力边界。 +description: Reference source plugin that demonstrates Google OIDC third-party login on the framework external-identity seam. + +# Plugin author or owning team. +# 插件作者或归属团队。 +author: linapro + +# Plugin homepage or documentation URL for extra project, design, or external references. +# 插件主页或文档地址,用于补充项目介绍、设计文档或外部说明链接。 +homepage: https://example.com/lina/plugins/linapro-oidc-google + +# Plugin license identifier. +# 插件许可证标识。 +license: Apache-2.0 + +# Plugin internationalization config. This follows the host i18n config shape; +# enabled plugins must ship manifest/i18n resources for the listed locales. +# 插件国际化配置。该配置与宿主 i18n 配置结构保持一致;启用后需提供对应语言资源。 +i18n: + enabled: true + default: en-US + locales: + - locale: en-US + nativeName: English + - locale: zh-CN + nativeName: 简体中文 + +# Plugin menu declaration list; the host syncs menus, button permissions, and route entries from it. +# 插件菜单声明列表,宿主按该列表同步菜单、按钮权限和路由入口。 +menus: + # Unique menu key; it must use the `plugin::` format. + # 菜单唯一键,必须使用 `plugin::` 格式。 + - key: plugin:linapro-oidc-google:settings + # Parent menu key pointing to the host extension center catalog. + # 父级菜单键,指向宿主扩展中心目录。 + parent_key: extension + # Menu display name. + # 菜单显示名称。 + name: Google OIDC 设置 + # Source plugin host route segment mounted into the governed dynamic route tree. + # 源码插件宿主内路由片段,会挂到插件治理后的动态路由树中。 + path: linapro-oidc-google-settings + # Menu render component; the dynamic page shell hosts plugin pages. + # 菜单渲染组件,动态页壳用于承载插件页面。 + component: system/plugin/dynamic-page + # Permission code required to access this menu. + # 菜单访问权限标识。 + perms: linapro-oidc-google:settings:view + # Menu icon using an Iconify icon name. + # 菜单图标,使用 Iconify 图标名称。 + icon: logos:google-icon + # Menu type enum. Allowed values: `D` directory, `M` menu page, `B` button permission node. + # 菜单类型枚举。可选值:`D` 表示目录,`M` 表示菜单页面,`B` 表示按钮权限点。 + type: M + # Menu sort order; lower values appear earlier. + # 菜单排序值,数值越小越靠前。 + sort: 10 + # Menu remark stored in host menu governance data. + # 菜单备注,写入宿主菜单治理数据。 + remark: Google OIDC 登录设置页 + # Unique button key mounted under the Google OIDC settings menu. + # 按钮唯一键,挂载在 Google OIDC 设置菜单下。 + - key: plugin:linapro-oidc-google:settings-update + # Parent menu key pointing to the Google OIDC settings menu. + # 父级菜单键,指向 Google OIDC 设置菜单。 + parent_key: plugin:linapro-oidc-google:settings + # Button display name. + # 按钮显示名称。 + name: Google OIDC 设置保存 + # Button permission code. + # 按钮权限标识。 + perms: linapro-oidc-google:settings:update + # Menu type enum. Allowed values: `D` directory, `M` menu page, `B` button permission node. + # 菜单类型枚举。可选值:`D` 表示目录,`M` 表示菜单页面,`B` 表示按钮权限点。 + type: B + # Button sort order; lower values appear earlier. + # 按钮排序值,数值越小越靠前。 + sort: 1 + # Button remark stored in host menu governance data. + # 按钮备注,写入宿主菜单治理数据。 + remark: Google OIDC 设置保存权限 diff --git a/linapro-oidc-google/plugin_embed.go b/linapro-oidc-google/plugin_embed.go new file mode 100644 index 00000000..1ba5e4ce --- /dev/null +++ b/linapro-oidc-google/plugin_embed.go @@ -0,0 +1,13 @@ +// Package pluginoidcgoogle is the source-plugin embedding entry for the +// linapro-oidc-google reference plugin. It only owns the embedded filesystem +// binding so the backend package can register plugin.yaml, frontend slot +// components, and manifest i18n resources with the host at compile time. +package pluginoidcgoogle + +import "embed" + +// EmbeddedFiles contains the plugin manifest, frontend slot components, and +// manifest resources shipped with the linapro-oidc-google source plugin. +// +//go:embed plugin.yaml frontend manifest +var EmbeddedFiles embed.FS From 352487dbee180832b3fa064f4f59c878142308a8 Mon Sep 17 00:00:00 2001 From: osindex Date: Tue, 7 Jul 2026 21:17:41 +0800 Subject: [PATCH 2/4] feat: add Discord OIDC login plugin on external-login seam Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- linapro-oidc-discord/Makefile | 7 + linapro-oidc-discord/README.md | 83 +++++ linapro-oidc-discord/README.zh-CN.md | 83 +++++ .../backend/api/settings/v1/settings.go | 28 ++ .../backend/api/settings/v1/settings_get.go | 17 + .../backend/api/settings/v1/settings_save.go | 26 ++ .../internal/controller/login/login.go | 24 ++ .../controller/login/login_callback.go | 218 ++++++++++++ .../internal/controller/login/login_start.go | 32 ++ .../internal/controller/settings/settings.go | 19 ++ .../controller/settings/settings_v1_get.go | 20 ++ .../controller/settings/settings_v1_save.go | 45 +++ .../backend/internal/service/oauth/oauth.go | 185 ++++++++++ .../internal/service/oauth/oauth_authorize.go | 60 ++++ .../internal/service/oauth/oauth_callback.go | 88 +++++ .../internal/service/oauth/oauth_code.go | 61 ++++ .../internal/service/oauth/oauth_config.go | 69 ++++ .../service/oauth/oauth_return_path.go | 14 + .../internal/service/oauth/oauth_state.go | 115 +++++++ .../internal/service/oauth/oauth_verifier.go | 71 ++++ .../service/oauth/oauth_verifier_http.go | 221 ++++++++++++ .../internal/service/settings/settings.go | 132 +++++++ .../service/settings/settings_code.go | 37 ++ .../service/settings/settings_read.go | 97 ++++++ .../service/settings/settings_save.go | 118 +++++++ linapro-oidc-discord/backend/plugin.go | 121 +++++++ .../frontend/pages/settings.vue | 321 ++++++++++++++++++ .../auth.login.after/discord-login-entry.vue | 72 ++++ linapro-oidc-discord/go.mod | 40 +++ linapro-oidc-discord/go.sum | 85 +++++ .../i18n/en-US/apidoc/plugin-api-main.json | 1 + .../manifest/i18n/en-US/error.json | 42 +++ .../manifest/i18n/en-US/plugin.json | 44 +++ .../i18n/zh-CN/apidoc/plugin-api-main.json | 91 +++++ .../manifest/i18n/zh-CN/error.json | 42 +++ .../manifest/i18n/zh-CN/plugin.json | 44 +++ .../sql/001-linapro-oidc-discord-settings.sql | 18 + .../001-linapro-oidc-discord-settings.sql | 15 + linapro-oidc-discord/plugin.yaml | 114 +++++++ linapro-oidc-discord/plugin_embed.go | 13 + 40 files changed, 2933 insertions(+) create mode 100644 linapro-oidc-discord/Makefile create mode 100644 linapro-oidc-discord/README.md create mode 100644 linapro-oidc-discord/README.zh-CN.md create mode 100644 linapro-oidc-discord/backend/api/settings/v1/settings.go create mode 100644 linapro-oidc-discord/backend/api/settings/v1/settings_get.go create mode 100644 linapro-oidc-discord/backend/api/settings/v1/settings_save.go create mode 100644 linapro-oidc-discord/backend/internal/controller/login/login.go create mode 100644 linapro-oidc-discord/backend/internal/controller/login/login_callback.go create mode 100644 linapro-oidc-discord/backend/internal/controller/login/login_start.go create mode 100644 linapro-oidc-discord/backend/internal/controller/settings/settings.go create mode 100644 linapro-oidc-discord/backend/internal/controller/settings/settings_v1_get.go create mode 100644 linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_authorize.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_code.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_config.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_return_path.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_state.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier.go create mode 100644 linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier_http.go create mode 100644 linapro-oidc-discord/backend/internal/service/settings/settings.go create mode 100644 linapro-oidc-discord/backend/internal/service/settings/settings_code.go create mode 100644 linapro-oidc-discord/backend/internal/service/settings/settings_read.go create mode 100644 linapro-oidc-discord/backend/internal/service/settings/settings_save.go create mode 100644 linapro-oidc-discord/backend/plugin.go create mode 100644 linapro-oidc-discord/frontend/pages/settings.vue create mode 100644 linapro-oidc-discord/frontend/slots/auth.login.after/discord-login-entry.vue create mode 100644 linapro-oidc-discord/go.mod create mode 100644 linapro-oidc-discord/go.sum create mode 100644 linapro-oidc-discord/manifest/i18n/en-US/apidoc/plugin-api-main.json create mode 100644 linapro-oidc-discord/manifest/i18n/en-US/error.json create mode 100644 linapro-oidc-discord/manifest/i18n/en-US/plugin.json create mode 100644 linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json create mode 100644 linapro-oidc-discord/manifest/i18n/zh-CN/error.json create mode 100644 linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json create mode 100644 linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql create mode 100644 linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql create mode 100644 linapro-oidc-discord/plugin.yaml create mode 100644 linapro-oidc-discord/plugin_embed.go diff --git a/linapro-oidc-discord/Makefile b/linapro-oidc-discord/Makefile new file mode 100644 index 00000000..e27b6e93 --- /dev/null +++ b/linapro-oidc-discord/Makefile @@ -0,0 +1,7 @@ +# LinaPro plugin Makefile. +# Shared code generation targets live in the repository root. + +PLUGIN_ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +REPO_ROOT := $(abspath $(PLUGIN_ROOT)/../../..) + +include $(REPO_ROOT)/hack/makefiles/plugin.codegen.mk diff --git a/linapro-oidc-discord/README.md b/linapro-oidc-discord/README.md new file mode 100644 index 00000000..56e2dd1f --- /dev/null +++ b/linapro-oidc-discord/README.md @@ -0,0 +1,83 @@ +# linapro-oidc-discord + +`linapro-oidc-discord` is a reference source plugin for `LinaPro` that demonstrates how to plug a third-party OIDC provider (Discord) into the new host external-identity seam. + +English | [简体中文](README.zh-CN.md) + +## What This Sample Demonstrates + +- Declares external-identity provider ownership at plugin init through `plugin.Providers().ProvideExternalIdentity("discord")` so the host will only accept `LoginByVerifiedIdentity` calls that name a provider owned by this plugin. +- Registers two browser-facing routes on a plugin-owned portal path group: + - `GET /portal/linapro-oidc-discord/login` builds the Discord authorize URL, persists an anti-CSRF state cookie, and redirects the browser to Discord. + - `GET /portal/linapro-oidc-discord/callback` validates the state, exchanges the OAuth code for a verified identity, calls `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(...)`, and redirects the browser back to the SPA login page with the host outcome encoded in the query string. +- Ships one Vue frontend slot component under `frontend/slots/auth.login.after/discord-login-entry.vue` that the workspace slot registry auto-mounts below the SPA login form when the plugin is installed and enabled. + +The OAuth code exchange and userinfo fetch are intentionally minimal and clearly stubbed. The reference verifier returns a deterministic verified identity from the incoming authorization code so the surrounding orchestration can be exercised end-to-end without a live Discord application. A real deployment must swap `oauthsvc.NewStubIdentityVerifier()` for an HTTP-backed implementation before this plugin can go to production. + +## Directory Layout + +```text +linapro-oidc-discord/ + plugin.yaml + plugin_embed.go + go.mod + Makefile + backend/ + plugin.go + internal/ + controller/login/ login-start + callback handlers + service/oauth/ authorize URL construction, state, verifier, callback + frontend/ + slots/auth.login.after/ "Continue with Discord" button component + manifest/ + i18n/en-US/ English i18n resources + i18n/zh-CN/ Simplified Chinese i18n resources + README.md + README.zh-CN.md +``` + +## New External-Identity Seam Integration + +The host owns provisioning, tenant candidate resolution, and token minting. The plugin only submits the verified identity DTO and forwards the outcome to the SPA login page. + +| Step | Actor | Action | +| --- | --- | --- | +| 1. User clicks "Continue with Discord" | Browser | Full-page navigation to `/portal/linapro-oidc-discord/login`. | +| 2. Plugin builds authorize URL | Plugin | `oauthsvc.BuildAuthorizeURL(ctx, ...)` returns a Discord authorize URL plus a fresh CSRF state; the plugin sets the state cookie and issues a 302 to Discord. | +| 3. Discord callback | Browser | Discord redirects back to `/portal/linapro-oidc-discord/callback?code=...&state=...`. | +| 4. Plugin verifies identity | Plugin | `oauthsvc.CompleteCallback(ctx, ...)` matches the cookie state, exchanges the code for `{subject, email, displayName}`. | +| 5. Host session issuance | Host | `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(ctx, externallogin.LoginInput{...})`. | +| 6. SPA redirect | Plugin | The plugin redirects back to the SPA login page with `accessToken`/`refreshToken` or `preToken`+tenant candidates encoded in the query. | + +Unlinked identities are rejected by the host with `AUTH_EXTERNAL_USER_NOT_PROVISIONED`. The plugin surfaces this to the user through the callback error redirect. + +## Configuration + +`oauth.DefaultConfig()` returns reference values with placeholder client credentials. Real deployments override the config at wiring time so a production Discord application can drive the flow: + +```go +cfg := oauthsvc.DefaultConfig() +cfg.ClientID = os.Getenv("DISCORD_CLIENT_ID") +cfg.ClientSecret = os.Getenv("DISCORD_CLIENT_SECRET") +cfg.RedirectURL = "https://your-host/portal/linapro-oidc-discord/callback" +``` + +For a production reference wiring, replace the stub verifier with an HTTP-backed one that: + +1. Exchanges the code for an access token at `TokenURL` (`https://discord.com/api/oauth2/token`). +2. Calls the userinfo endpoint (`https://discord.com/api/users/@me`) with the access token. +3. Projects the returned `id`, `email`, and `global_name`/`username` into `oauthsvc.VerifiedIdentity`. + +## Frontend Slot + +The Vue slot component lives at `frontend/slots/auth.login.after/discord-login-entry.vue`. The workspace slot registry auto-discovers it at build time; the runtime registry hides the button when the plugin is not installed or is disabled. The button triggers a full-page navigation to the login-start route so the browser cookie set by the plugin controller is respected during the OIDC handshake. + +Its `pluginSlotMeta.order` is `20`, so when both `linapro-oidc-google` (order `10`) and this plugin are enabled, Google renders first and Discord renders below it. + +## Review Checklist + +- provider ownership is declared through `ProvideExternalIdentity` +- login routes stay on the plugin-owned portal path group, outside the `/x` API namespace +- provisioning, tenant resolution, and token minting stay host-owned +- OAuth stub is documented and easy to replace +- Vue slot component is only rendered when the plugin is installed and enabled diff --git a/linapro-oidc-discord/README.zh-CN.md b/linapro-oidc-discord/README.zh-CN.md new file mode 100644 index 00000000..236be3be --- /dev/null +++ b/linapro-oidc-discord/README.zh-CN.md @@ -0,0 +1,83 @@ +# linapro-oidc-discord + +`linapro-oidc-discord` 是 `LinaPro` 提供的参考源码插件,用于演示如何把第三方 OIDC(Discord)接入到宿主新版外部身份接缝上。 + +[English](README.md) | 简体中文 + +## 示例演示内容 + +- 通过 `plugin.Providers().ProvideExternalIdentity("discord")` 在插件初始化阶段声明外部身份 provider 归属,宿主据此拒绝任何声明其他 provider 的 `LoginByVerifiedIdentity` 请求。 +- 在插件私有 portal 路径组下注册两个面向浏览器的公开路由: + - `GET /portal/linapro-oidc-discord/login`:构造 Discord authorize URL,写入 anti-CSRF 状态 Cookie,并 302 跳转到 Discord。 + - `GET /portal/linapro-oidc-discord/callback`:校验 state、用授权码换取一个验证过的身份、调用 `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(...)`,最后把宿主返回的 token 或 pre-token 通过 query 参数带回 SPA 登录页。 +- 在 `frontend/slots/auth.login.after/discord-login-entry.vue` 提供一个 Vue 插槽组件,前端 slot registry 会在插件安装并启用时自动挂载到登录表单下方。 + +OAuth 的 code 兑换和 userinfo 获取是刻意保留的极简 stub 实现:默认的 verifier 直接根据传入的授权码生成一个稳定的身份 DTO,方便在没有真实 Discord 应用的情况下把整个流程串起来演练。真实部署必须将 `oauthsvc.NewStubIdentityVerifier()` 替换为基于 HTTP 的 Discord OIDC 实现。 + +## 目录结构 + +```text +linapro-oidc-discord/ + plugin.yaml + plugin_embed.go + go.mod + Makefile + backend/ + plugin.go + internal/ + controller/login/ login-start + callback handlers + service/oauth/ authorize URL 构造、state、verifier、callback + frontend/ + slots/auth.login.after/ "使用 Discord 账号登录" 按钮组件 + manifest/ + i18n/en-US/ 英文语言包 + i18n/zh-CN/ 简体中文语言包 + README.md + README.zh-CN.md +``` + +## 与新版外部身份接缝的集成 + +宿主负责用户开通、租户候选、Token 发放;插件只提交“已验证”的身份 DTO,并把宿主返回结果带回 SPA。 + +| 步骤 | 参与方 | 动作 | +| --- | --- | --- | +| 1. 用户点击“使用 Discord 账号登录” | 浏览器 | 整页跳转到 `/portal/linapro-oidc-discord/login`。 | +| 2. 插件构造 authorize URL | 插件 | `oauthsvc.BuildAuthorizeURL(ctx, ...)` 返回 Discord authorize URL 和新 state;插件写入 state Cookie 后 302 到 Discord。 | +| 3. Discord 回调 | 浏览器 | Discord 重定向回 `/portal/linapro-oidc-discord/callback?code=...&state=...`。 | +| 4. 插件校验身份 | 插件 | `oauthsvc.CompleteCallback(ctx, ...)` 校对 state、用 code 换 `{subject, email, displayName}`。 | +| 5. 宿主发放会话 | 宿主 | `Services.Auth().ExternalLogin().LoginByVerifiedIdentity(ctx, externallogin.LoginInput{...})`。 | +| 6. 回到 SPA | 插件 | 插件把 `accessToken`/`refreshToken` 或 `preToken` + 租户候选拼进 query,再重定向回 SPA 登录页。 | + +宿主对未绑定本地用户的外部身份返回 `AUTH_EXTERNAL_USER_NOT_PROVISIONED`,插件通过回调 error 重定向把该情况透传给前端。 + +## 配置 + +`oauth.DefaultConfig()` 返回带占位客户端凭据的参考配置。真实部署需要在装配阶段覆盖: + +```go +cfg := oauthsvc.DefaultConfig() +cfg.ClientID = os.Getenv("DISCORD_CLIENT_ID") +cfg.ClientSecret = os.Getenv("DISCORD_CLIENT_SECRET") +cfg.RedirectURL = "https://your-host/portal/linapro-oidc-discord/callback" +``` + +生产实现需要把 stub verifier 换成真正走 HTTP 的实现: + +1. 调用 `TokenURL`(`https://discord.com/api/oauth2/token`)用 code 换 access token。 +2. 用 access token 调用 userinfo 端点(`https://discord.com/api/users/@me`)。 +3. 把返回的 `id`、`email`、`global_name`/`username` 投影为 `oauthsvc.VerifiedIdentity`。 + +## 前端插槽 + +Vue 插槽组件位于 `frontend/slots/auth.login.after/discord-login-entry.vue`。构建期由 slot registry 自动发现;运行期会在插件未安装或已禁用时隐藏按钮。按钮点击时执行整页跳转,以便浏览器保留插件写入的 Cookie。 + +`pluginSlotMeta.order` 设为 `20`,与 `linapro-oidc-google`(order `10`)同时启用时,Google 按钮先渲染,Discord 按钮渲染在其下方。 + +## 审查清单 + +- 已通过 `ProvideExternalIdentity` 声明 provider 归属 +- 登录路由挂在插件私有 portal 路径组,未占用 `/x` API 命名空间 +- 用户开通、租户解析、Token 发放全部保持宿主拥有 +- OAuth stub 有清晰的替换指引 +- Vue 插槽组件仅在插件安装并启用时才被挂载 diff --git a/linapro-oidc-discord/backend/api/settings/v1/settings.go b/linapro-oidc-discord/backend/api/settings/v1/settings.go new file mode 100644 index 00000000..f72eebdb --- /dev/null +++ b/linapro-oidc-discord/backend/api/settings/v1/settings.go @@ -0,0 +1,28 @@ +// Package v1 declares the linapro-oidc-discord settings API DTOs. Settings +// values are persisted through the host sys_config seam and read by the OAuth +// login orchestration at request time. + +package v1 + +// SettingsItem is the settings projection returned by the get endpoint. The +// client secret is always masked; the raw value is never returned by the API. +type SettingsItem struct { + // ClientId is the Discord OAuth 2.0 client ID issued by the Discord Developer Portal. + ClientId string `json:"clientId" dc:"Discord OAuth 2.0 client ID issued by the Discord Developer Portal" eg:"1234567890123456789"` + // ClientSecretMasked is the masked client secret indicator. It is never the + // plaintext value: a non-empty stored secret returns a fixed mask, an empty + // stored secret returns an empty string. + ClientSecretMasked string `json:"clientSecretMasked" dc:"Masked client secret indicator; non-empty stored value returns a fixed mask, empty stored value returns empty string. Plaintext secrets are never returned." eg:"************"` + // ClientSecretConfigured reports whether a client secret is currently + // stored, so the UI can distinguish a masked value from an unset value. + ClientSecretConfigured bool `json:"clientSecretConfigured" dc:"True when a client secret is currently stored; used by the UI to distinguish masked from unset" eg:"true"` + // RedirectUrl is the callback URL registered with Discord. + RedirectUrl string `json:"redirectUrl" dc:"Fully-qualified callback URL registered with Discord; must resolve to the plugin callback route" eg:"https://your-host/portal/linapro-oidc-discord/callback"` + // EnableBackendRedirect reports whether SSO token delivery to third-party + // receiver URLs is enabled. + EnableBackendRedirect bool `json:"enableBackendRedirect" dc:"True when SSO token delivery to third-party receiver URLs is enabled" eg:"false"` + // DefaultBackendRedirect is the SPA landing path used after a normal login. + DefaultBackendRedirect string `json:"defaultBackendRedirect" dc:"Workspace landing path used after a normal external login; empty keeps the host default" eg:"/dashboard/analytics"` + // BackendRedirects is the JSON object mapping state keys to receiver URLs. + BackendRedirects string `json:"backendRedirects" dc:"JSON object mapping business state keys to third-party SSO receiver URLs" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` +} diff --git a/linapro-oidc-discord/backend/api/settings/v1/settings_get.go b/linapro-oidc-discord/backend/api/settings/v1/settings_get.go new file mode 100644 index 00000000..a9b51b27 --- /dev/null +++ b/linapro-oidc-discord/backend/api/settings/v1/settings_get.go @@ -0,0 +1,17 @@ +// settings_get.go declares the get-settings request and response DTOs for the +// linapro-oidc-discord settings API. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// GetSettingsReq is the request for querying the linapro-oidc-discord settings. +type GetSettingsReq struct { + g.Meta `path:"/plugins/linapro-oidc-discord/settings" method:"get" tags:"Discord OIDC Login" summary:"Query Discord OIDC settings" dc:"Return the persisted linapro-oidc-discord settings for the admin settings page. The client secret is always returned as a masked indicator; plaintext secrets are never sent to the client." permission:"linapro-oidc-discord:settings:view"` +} + +// GetSettingsRes is the response for querying the linapro-oidc-discord settings. +type GetSettingsRes struct { + // Settings carries the projected settings row read from sys_config. + Settings *SettingsItem `json:"settings" dc:"Persisted settings values with client secret masked" eg:"{}"` +} diff --git a/linapro-oidc-discord/backend/api/settings/v1/settings_save.go b/linapro-oidc-discord/backend/api/settings/v1/settings_save.go new file mode 100644 index 00000000..ce659216 --- /dev/null +++ b/linapro-oidc-discord/backend/api/settings/v1/settings_save.go @@ -0,0 +1,26 @@ +// settings_save.go declares the save-settings request and response DTOs for +// the linapro-oidc-discord settings API. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// SaveSettingsReq is the request for saving the linapro-oidc-discord settings. +type SaveSettingsReq struct { + g.Meta `path:"/plugins/linapro-oidc-discord/settings" method:"put" tags:"Discord OIDC Login" summary:"Save Discord OIDC settings" dc:"Persist the linapro-oidc-discord settings to sys_config. An empty or masked client secret keeps the previously stored value; a non-empty non-masked value replaces it." permission:"linapro-oidc-discord:settings:update"` + ClientId string `json:"clientId" v:"max-length:256" dc:"Discord OAuth 2.0 client ID; empty string clears the stored value" eg:"1234567890123456789"` + ClientSecret string `json:"clientSecret" v:"max-length:512" dc:"Discord OAuth 2.0 client secret; empty string or the masked indicator keeps the previously stored value, any other value replaces it" eg:"discord-secret-value"` + RedirectUrl string `json:"redirectUrl" v:"max-length:512" dc:"Fully-qualified callback URL registered with Discord; empty string clears the stored value" eg:"https://your-host/portal/linapro-oidc-discord/callback"` + // EnableBackendRedirect toggles SSO token delivery to third-party receivers. + EnableBackendRedirect bool `json:"enableBackendRedirect" dc:"Enable SSO token delivery to third-party receiver URLs matched by state key" eg:"false"` + // DefaultBackendRedirect sets the SPA landing path after a normal login. + DefaultBackendRedirect string `json:"defaultBackendRedirect" v:"max-length:512" dc:"Workspace landing path after a normal external login; empty string keeps the host default" eg:"/dashboard/analytics"` + // BackendRedirects sets the state-key-to-receiver-URL JSON object. + BackendRedirects string `json:"backendRedirects" v:"max-length:4096" dc:"JSON object mapping business state keys to third-party SSO receiver URLs; empty string clears all rules" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` +} + +// SaveSettingsRes is the response for saving the linapro-oidc-discord settings. +type SaveSettingsRes struct { + // Settings carries the fresh masked projection after the save applies. + Settings *SettingsItem `json:"settings" dc:"Fresh settings projection after the save applies, with client secret masked" eg:"{}"` +} diff --git a/linapro-oidc-discord/backend/internal/controller/login/login.go b/linapro-oidc-discord/backend/internal/controller/login/login.go new file mode 100644 index 00000000..888e690c --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/login/login.go @@ -0,0 +1,24 @@ +// Package login implements the HTTP entry points for the Discord OIDC login +// flow exposed by the linapro-oidc-discord plugin. The controller is +// intentionally thin; all orchestration lives in the oauth service so the +// route handlers only translate between GoFrame request objects and service +// inputs/outputs. +package login + +import ( + oauthsvc "lina-plugin-linapro-oidc-discord/backend/internal/service/oauth" + settingssvc "lina-plugin-linapro-oidc-discord/backend/internal/service/settings" +) + +// ControllerV1 is the linapro-oidc-discord login controller. It handles the +// browser-facing login-start and callback routes registered on the portal +// route group. +type ControllerV1 struct { + oauthSvc oauthsvc.Service // oauthSvc orchestrates the Discord OIDC login flow. + settingsSvc settingssvc.Service // settingsSvc supplies SSO delivery rules and the SPA landing path at request time. +} + +// NewV1 creates and returns one Discord OIDC login controller instance. +func NewV1(oauthSvc oauthsvc.Service, settingsSvc settingssvc.Service) *ControllerV1 { + return &ControllerV1{oauthSvc: oauthSvc, settingsSvc: settingsSvc} +} diff --git a/linapro-oidc-discord/backend/internal/controller/login/login_callback.go b/linapro-oidc-discord/backend/internal/controller/login/login_callback.go new file mode 100644 index 00000000..e0bbcd37 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/login/login_callback.go @@ -0,0 +1,218 @@ +// login_callback.go implements the browser-facing GET /callback route that +// consumes the Discord callback and hands the verified identity to the host +// external-login seam. + +package login + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gogf/gf/v2/net/ghttp" + + "lina-core/pkg/logger" + + oauthsvc "lina-plugin-linapro-oidc-discord/backend/internal/service/oauth" + settingssvc "lina-plugin-linapro-oidc-discord/backend/internal/service/settings" +) + +// defaultReturnPath is used when the plugin cannot resolve one from the +// configured login return path. The SPA renders its login page at this path +// in the default LinaPro workspace layout; the outcome query must live inside +// the hash fragment so the SPA hash router exposes it through route.query. +const defaultReturnPath = "/admin/#/auth/login" + +// Callback handles GET /portal/linapro-oidc-discord/callback. It forwards the +// callback values to the OAuth service, which validates the self-contained +// signed state (no cookie required), and redirects the browser back to the +// SPA login page with the outcome encoded in the query string. +func (c *ControllerV1) Callback(request *ghttp.Request) { + ctx := request.Context() + callback, err := c.oauthSvc.CompleteCallback(ctx, oauthsvc.CallbackInput{ + Code: request.Get("code").String(), + State: request.Get("state").String(), + }) + returnPath := c.resolveReturnPath() + if err != nil { + logger.Warningf(ctx, "linapro-oidc-discord callback failed: %v", err) + redirectURL := buildErrorRedirect(returnPath, err.Error()) + request.Response.RedirectTo(redirectURL, http.StatusFound) + return + } + // The business state key is recovered from the signed state token. + stateKey := strings.TrimSpace(callback.StateKey) + settings := c.loadSettingsSnapshot(ctx) + // SSO token delivery: when enabled and the business state key matches one + // configured rule, the tokens are delivered straight to the third-party + // receiver URL and the browser never visits the SPA login page. + if receiver := resolveSSOReceiver(ctx, settings, stateKey, callback); receiver != "" { + request.Response.RedirectTo(buildReceiverRedirect(receiver, stateKey, callback), http.StatusFound) + return + } + redirectURL := buildSuccessRedirect(returnPath, callback, resolveSPALanding(settings)) + request.Response.RedirectTo(redirectURL, http.StatusFound) +} + +// loadSettingsSnapshot reads the persisted settings, degrading to nil so a +// temporarily unavailable settings store never breaks the login redirect. +func (c *ControllerV1) loadSettingsSnapshot(ctx context.Context) *settingssvc.Snapshot { + if c.settingsSvc == nil { + return nil + } + snapshot, err := c.settingsSvc.Load(ctx) + if err != nil { + logger.Warningf(ctx, "linapro-oidc-discord callback settings load failed: %v", err) + return nil + } + return snapshot +} + +// resolveSPALanding returns the configured SPA landing path for normal logins +// or an empty string to keep the host default landing. +func resolveSPALanding(settings *settingssvc.Snapshot) string { + if settings == nil { + return "" + } + return strings.TrimSpace(settings.DefaultBackendRedirect) +} + +// resolveSSOReceiver returns the third-party receiver URL when SSO delivery is +// enabled, the login produced a token pair, and the business state key matches +// one configured rule. Pre-token (multi-tenant) outcomes always fall back to +// the SPA flow because tenant selection requires the workspace UI. +func resolveSSOReceiver(ctx context.Context, settings *settingssvc.Snapshot, stateKey string, callback *oauthsvc.CallbackOutput) string { + if settings == nil || !settings.EnableBackendRedirect || stateKey == "" { + return "" + } + if callback == nil || callback.AccessToken == "" { + return "" + } + rulesJSON := strings.TrimSpace(settings.BackendRedirects) + if rulesJSON == "" { + return "" + } + var rules map[string]string + if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil { + logger.Warningf(ctx, "linapro-oidc-discord backend redirect rules malformed: %v", err) + return "" + } + return strings.TrimSpace(rules[stateKey]) +} + +// buildReceiverRedirect appends the token pair and the echoed state key to the +// receiver URL so the third-party system can establish its own session. +func buildReceiverRedirect(receiver string, stateKey string, callback *oauthsvc.CallbackOutput) string { + query := url.Values{} + query.Set("provider", "discord") + query.Set("state", stateKey) + query.Set("accessToken", callback.AccessToken) + if callback.RefreshToken != "" { + query.Set("refreshToken", callback.RefreshToken) + } + separator := "?" + if strings.Contains(receiver, "?") { + separator = "&" + } + return receiver + separator + query.Encode() +} + +// resolveReturnPath returns the SPA login return path with a safe default. +// Falling back to a stable SPA path guarantees the browser is never stranded +// on the plugin callback URL. +func (c *ControllerV1) resolveReturnPath() string { + if path := c.oauthSvc.LoginReturnPath(); path != "" { + return path + } + return defaultReturnPath +} + +// buildErrorRedirect renders one SPA redirect URL for a failed callback. +func buildErrorRedirect(returnPath string, message string) string { + query := url.Values{} + query.Set("externalLogin", "1") + query.Set("provider", "discord") + query.Set("status", "error") + query.Set("message", message) + return appendQuery(returnPath, query) +} + +// buildSuccessRedirect renders one SPA redirect URL for a successful callback. +// spaLanding optionally overrides the workspace landing path the SPA navigates +// to after storing the tokens. +func buildSuccessRedirect(returnPath string, callback *oauthsvc.CallbackOutput, spaLanding string) string { + query := url.Values{} + query.Set("externalLogin", "1") + query.Set("provider", "discord") + if spaLanding != "" { + query.Set("redirect", spaLanding) + } + if callback == nil { + query.Set("status", "error") + query.Set("message", "empty external login response") + return appendQuery(returnPath, query) + } + if callback.PreToken != "" { + query.Set("status", "select-tenant") + query.Set("preToken", callback.PreToken) + query.Set("tenantCount", strconv.Itoa(len(callback.TenantCandidates))) + if encoded := encodeTenantCandidates(callback); encoded != "" { + query.Set("tenants", encoded) + } + return appendQuery(returnPath, query) + } + query.Set("status", "signed-in") + if callback.AccessToken != "" { + query.Set("accessToken", callback.AccessToken) + } + if callback.RefreshToken != "" { + query.Set("refreshToken", callback.RefreshToken) + } + return appendQuery(returnPath, query) +} + +// encodeTenantCandidates serializes the tenant candidates into the compact +// JSON array the SPA login page parses to render the two-stage tenant +// selection after an external login. Encoding failures degrade to an empty +// string so the redirect never breaks over an optional enrichment. +func encodeTenantCandidates(callback *oauthsvc.CallbackOutput) string { + if callback == nil || len(callback.TenantCandidates) == 0 { + return "" + } + type tenantProjection struct { + Id int `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Status string `json:"status"` + } + items := make([]tenantProjection, 0, len(callback.TenantCandidates)) + for _, tenant := range callback.TenantCandidates { + items = append(items, tenantProjection{ + Id: tenant.ID, + Code: tenant.Code, + Name: tenant.Name, + Status: tenant.Status, + }) + } + encoded, err := json.Marshal(items) + if err != nil { + return "" + } + return string(encoded) +} + +// appendQuery attaches encoded query parameters to the given return path +// while preserving any query the path itself already carries. +func appendQuery(returnPath string, query url.Values) string { + if returnPath == "" { + returnPath = defaultReturnPath + } + separator := "?" + if strings.Contains(returnPath, "?") { + separator = "&" + } + return returnPath + separator + query.Encode() +} diff --git a/linapro-oidc-discord/backend/internal/controller/login/login_start.go b/linapro-oidc-discord/backend/internal/controller/login/login_start.go new file mode 100644 index 00000000..dbd5a5b1 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/login/login_start.go @@ -0,0 +1,32 @@ +// login_start.go implements the browser-facing GET /login route that +// initiates the Discord OIDC authorize redirect. The anti-CSRF state is a +// self-contained HMAC-signed token embedded in the authorize URL, so no +// cookie is required to survive the cross-site round trip through Discord. + +package login + +import ( + "net/http" + + "github.com/gogf/gf/v2/net/ghttp" + + "lina-core/pkg/logger" +) + +// Start handles GET /portal/linapro-oidc-discord/login. It asks the OAuth +// service for one authorize URL carrying the signed state (with the optional +// ?state= business key embedded) and issues an HTTP 302 redirect to +// Discord. On configuration errors it redirects back to the SPA login page +// with an error hint instead of stranding the browser on the plugin route. +func (c *ControllerV1) Start(request *ghttp.Request) { + ctx := request.Context() + stateKey := request.Get("state").String() + authorize, err := c.oauthSvc.BuildAuthorizeURL(ctx, stateKey) + if err != nil { + logger.Warningf(ctx, "linapro-oidc-discord authorize URL build failed: %v", err) + redirectURL := buildErrorRedirect(c.resolveReturnPath(), err.Error()) + request.Response.RedirectTo(redirectURL, http.StatusFound) + return + } + request.Response.RedirectTo(authorize.URL, http.StatusFound) +} diff --git a/linapro-oidc-discord/backend/internal/controller/settings/settings.go b/linapro-oidc-discord/backend/internal/controller/settings/settings.go new file mode 100644 index 00000000..0f1e91ed --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/settings/settings.go @@ -0,0 +1,19 @@ +// Package settings implements the linapro-oidc-discord settings HTTP +// controller. The controller stays thin: it forwards DTO values to the +// settings service and projects the masked settings result back into the +// response DTO. +package settings + +import ( + settingssvc "lina-plugin-linapro-oidc-discord/backend/internal/service/settings" +) + +// ControllerV1 is the linapro-oidc-discord settings controller. +type ControllerV1 struct { + settingsSvc settingssvc.Service // settingsSvc persists and projects the plugin settings. +} + +// NewV1 creates and returns a new linapro-oidc-discord settings controller instance. +func NewV1(settingsSvc settingssvc.Service) *ControllerV1 { + return &ControllerV1{settingsSvc: settingsSvc} +} diff --git a/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_get.go b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_get.go new file mode 100644 index 00000000..a9bf6ce3 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_get.go @@ -0,0 +1,20 @@ +// settings_v1_get.go implements the get-settings endpoint of the +// linapro-oidc-discord settings controller. + +package settings + +import ( + "context" + + v1 "lina-plugin-linapro-oidc-discord/backend/api/settings/v1" +) + +// GetSettings returns the persisted settings projection with the client +// secret masked. +func (c *ControllerV1) GetSettings(ctx context.Context, _ *v1.GetSettingsReq) (res *v1.GetSettingsRes, err error) { + projection, err := c.settingsSvc.Get(ctx) + if err != nil { + return nil, err + } + return &v1.GetSettingsRes{Settings: projectSettingsItem(projection)}, nil +} diff --git a/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go new file mode 100644 index 00000000..8e19d63c --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go @@ -0,0 +1,45 @@ +// settings_v1_save.go implements the save-settings endpoint of the +// linapro-oidc-discord settings controller and the shared projection helper. + +package settings + +import ( + "context" + + v1 "lina-plugin-linapro-oidc-discord/backend/api/settings/v1" + settingssvc "lina-plugin-linapro-oidc-discord/backend/internal/service/settings" +) + +// SaveSettings persists the submitted settings values and returns the fresh +// masked projection. An empty or masked client secret keeps the previously +// stored value. +func (c *ControllerV1) SaveSettings(ctx context.Context, req *v1.SaveSettingsReq) (res *v1.SaveSettingsRes, err error) { + projection, err := c.settingsSvc.Save(ctx, settingssvc.SaveInput{ + ClientID: req.ClientId, + ClientSecret: req.ClientSecret, + RedirectURL: req.RedirectUrl, + EnableBackendRedirect: req.EnableBackendRedirect, + DefaultBackendRedirect: req.DefaultBackendRedirect, + BackendRedirects: req.BackendRedirects, + }) + if err != nil { + return nil, err + } + return &v1.SaveSettingsRes{Settings: projectSettingsItem(projection)}, nil +} + +// projectSettingsItem maps the service projection into the API DTO shape. +func projectSettingsItem(projection *settingssvc.Projection) *v1.SettingsItem { + if projection == nil { + return &v1.SettingsItem{} + } + return &v1.SettingsItem{ + ClientId: projection.ClientID, + ClientSecretMasked: projection.ClientSecretMasked, + ClientSecretConfigured: projection.ClientSecretConfigured, + RedirectUrl: projection.RedirectURL, + EnableBackendRedirect: projection.EnableBackendRedirect, + DefaultBackendRedirect: projection.DefaultBackendRedirect, + BackendRedirects: projection.BackendRedirects, + } +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth.go new file mode 100644 index 00000000..477e10b3 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth.go @@ -0,0 +1,185 @@ +// Package oauth implements the Discord OIDC login orchestration for the +// linapro-oidc-discord reference plugin. It builds the Discord authorize URL, +// consumes callback code and state values, exchanges the code for a verified +// identity, and hands the verified identity off to the host external-login +// seam so the host can complete provisioning, tenant selection, and token +// minting under its own governance. +package oauth + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + "lina-core/pkg/plugin/capability/authcap/externallogin" + + settingssvc "lina-plugin-linapro-oidc-discord/backend/internal/service/settings" +) + +// Provider is the stable external-identity provider ID owned by this plugin. +// The host validates that external-login requests only pass this provider ID +// when the caller is the linapro-oidc-discord plugin. +const Provider = "discord" + +// Service defines the Discord OIDC login orchestration contract. The service +// separates the authorize URL construction and callback flow so the HTTP +// controller only forwards raw request values and never talks to the host +// external-login seam directly. +type Service interface { + // BuildAuthorizeURL prepares one authorize URL that the browser should + // redirect to when the user clicks the "Continue with Discord" button. The + // optional stateKey is embedded into the signed state token so the callback + // can match it against the configured SSO delivery rules without relying + // on browser cookies. + BuildAuthorizeURL(ctx context.Context, stateKey string) (AuthorizeRequest, error) + // CompleteCallback validates the signed callback state, exchanges the OAuth + // code for a verified identity, calls the host external-login seam, and + // returns one login outcome the controller renders as a redirect. + CompleteCallback(ctx context.Context, in CallbackInput) (*CallbackOutput, error) + // LoginReturnPath returns the SPA path the callback controller redirects to + // after the host external-login exchange completes. It reflects the + // configured LoginReturnPath value so wiring stays in one place. + LoginReturnPath() string +} + +// AuthorizeRequest describes the authorize redirect the browser should follow. +type AuthorizeRequest struct { + // URL is the fully-qualified Discord authorize URL, including client_id, + // redirect_uri, scope, response_type, and state parameters. + URL string + // State is the self-contained signed state token embedded in the URL. It + // is returned separately for audit logging only; no cookie is required. + State string +} + +// CallbackInput carries the raw values returned by the Discord callback. +type CallbackInput struct { + // Code is the OAuth authorization code that the plugin exchanges for a + // verified identity. + Code string + // State is the signed state token Discord echoed back. It is validated by + // signature and expiry; no stored copy is needed. + State string +} + +// CallbackOutput carries the host external-login outcome projected into the +// shape the controller needs to build the SPA redirect. +type CallbackOutput struct { + // AccessToken is set when the resolved user has zero or one active tenant. + AccessToken string + // RefreshToken is set together with AccessToken for single-tenant users. + RefreshToken string + // PreToken is set when the resolved user has more than one active tenant + // and the SPA must complete tenant selection through the host auth API. + PreToken string + // TenantCandidates lists the tenants the user may select from when + // PreToken is set. The controller may serialize it into query parameters + // or defer the display to the SPA. + TenantCandidates []externallogin.TenantCandidate + // StateKey is the business state key recovered from the signed state + // token; the controller matches it against the SSO delivery rules. + StateKey string +} + +// Interface compliance assertion for the default OIDC login service implementation. +var _ Service = (*serviceImpl)(nil) + +// serviceImpl orchestrates the Discord OIDC login for the reference plugin. +// External-login is the host runtime dependency: the host owns provisioning, +// tenant candidate resolution, and token minting once the plugin submits a +// verified identity. The settings are re-resolved on every OAuth request so +// admin edits from the settings page take effect without a restart. +type serviceImpl struct { + externalLoginSvc externallogin.Service // externalLoginSvc completes host session issuance for verified identities. + configResolver *ConfigResolver // configResolver layers persisted settings over static defaults per request. + verifier IdentityVerifier // verifier exchanges an OAuth code for a verified identity. + stateCodec StateCodec // stateCodec signs and validates self-contained state tokens. +} + +// New creates and returns a new Discord OIDC login service instance. Each +// dependency is a narrow contract so the reference implementation can wire a +// production HTTP verifier at real deployment time while unit tests inject a +// deterministic verifier. The shared configResolver is also consumed by the +// HTTP identity verifier so both read identical request-time settings. +func New( + externalLoginSvc externallogin.Service, + configResolver *ConfigResolver, + verifier IdentityVerifier, + stateCodec StateCodec, +) Service { + return &serviceImpl{ + externalLoginSvc: externalLoginSvc, + configResolver: configResolver, + verifier: verifier, + stateCodec: stateCodec, + } +} + +// resolveConfig returns the effective request-time configuration through the +// shared resolver, degrading to the zero config when wiring is incomplete. +func (s *serviceImpl) resolveConfig(ctx context.Context) Config { + if s == nil || s.configResolver == nil { + return Config{} + } + return s.configResolver.ResolveConfig(ctx) +} + +// ConfigResolver layers the persisted admin settings on top of the static +// defaults per request so settings edits take effect immediately. It is +// shared between the login orchestration and the HTTP identity verifier so +// both read identical request-time settings. +type ConfigResolver struct { + settingsSvc settingssvc.Service // settingsSvc supplies persisted admin settings; nil keeps static config only. + config Config // config carries the static defaults layered under persisted settings. +} + +// NewConfigResolver creates the shared request-time configuration resolver. +func NewConfigResolver(settingsSvc settingssvc.Service, config Config) *ConfigResolver { + return &ConfigResolver{settingsSvc: settingsSvc, config: config} +} + +// ResolveConfig layers persisted settings over the static defaults; persisted +// empty fields keep the static default, and an unset redirect URL is derived +// from the live request host. +func (r *ConfigResolver) ResolveConfig(ctx context.Context) Config { + if r == nil { + return Config{} + } + resolved := r.config + if r.settingsSvc != nil { + snapshot, err := r.settingsSvc.Load(ctx) + if err == nil && snapshot != nil { + if snapshot.ClientID != "" { + resolved.ClientID = snapshot.ClientID + } + if snapshot.ClientSecret != "" { + resolved.ClientSecret = snapshot.ClientSecret + } + if snapshot.RedirectURL != "" { + resolved.RedirectURL = snapshot.RedirectURL + } + } + // A load failure keeps the static defaults so a temporarily + // unavailable settings store does not take the login entry down; the + // settings service already logs the underlying failure. + } + if resolved.RedirectURL == "" { + resolved.RedirectURL = deriveCallbackURL(ctx) + } + return resolved +} + +// deriveCallbackURL builds the plugin callback URL from the current request +// host so deployments do not need to configure the redirect URL manually. The +// callback path is fixed by the plugin's portal route registration. +func deriveCallbackURL(ctx context.Context) string { + request := g.RequestFromCtx(ctx) + if request == nil || request.Host == "" { + return "" + } + scheme := "http" + if request.TLS != nil || request.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return scheme + "://" + request.Host + "/portal/linapro-oidc-discord/callback" +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_authorize.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_authorize.go new file mode 100644 index 00000000..3aa880c3 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_authorize.go @@ -0,0 +1,60 @@ +// oauth_authorize.go implements the Discord authorize URL construction step +// of the Discord OIDC login flow. The controller invokes BuildAuthorizeURL +// and then issues an HTTP 302 redirect using the returned URL. The anti-CSRF +// state is a self-contained HMAC-signed token, so no cookie is required to +// survive the round trip through Discord. + +package oauth + +import ( + "context" + "net/url" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// BuildAuthorizeURL prepares one Discord authorize redirect. The optional +// stateKey is embedded into the signed state token so the callback can match +// it against the configured SSO delivery rules. +func (s *serviceImpl) BuildAuthorizeURL(ctx context.Context, stateKey string) (AuthorizeRequest, error) { + // Re-resolve settings per request so admin edits apply without a restart. + config := s.resolveConfig(ctx) + if strings.TrimSpace(config.ClientID) == "" || strings.TrimSpace(config.RedirectURL) == "" || strings.TrimSpace(config.AuthorizeURL) == "" { + return AuthorizeRequest{}, bizerr.WrapCode( + gerror.New("oidc discord: authorize configuration missing"), + CodeConfigMissing, + ) + } + if s.stateCodec == nil { + return AuthorizeRequest{}, bizerr.WrapCode( + gerror.New("oidc discord: state codec is missing"), + CodeStateGenerateFailed, + ) + } + state, err := s.stateCodec.Encode(ctx, stateKey, config.ClientSecret) + if err != nil { + return AuthorizeRequest{}, err + } + authorizeURL, err := url.Parse(config.AuthorizeURL) + if err != nil { + return AuthorizeRequest{}, bizerr.WrapCode( + gerror.Wrap(err, "oidc discord: authorize URL is not a valid URL"), + CodeConfigMissing, + ) + } + query := authorizeURL.Query() + query.Set("client_id", config.ClientID) + query.Set("redirect_uri", config.RedirectURL) + query.Set("response_type", "code") + query.Set("scope", strings.Join(config.Scopes, " ")) + query.Set("state", state) + query.Set("prompt", "consent") + authorizeURL.RawQuery = query.Encode() + return AuthorizeRequest{ + URL: authorizeURL.String(), + State: state, + }, nil +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go new file mode 100644 index 00000000..7a110d1b --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go @@ -0,0 +1,88 @@ +// oauth_callback.go implements the Discord callback step of the OIDC login +// flow. The controller invokes CompleteCallback with the raw callback values +// and forwards the resulting tokens or pre-token to the SPA login page. The +// state parameter is a self-contained HMAC-signed token validated by +// signature and expiry, so the flow does not depend on cookies surviving the +// cross-site round trip through Discord. + +package oauth + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" + "lina-core/pkg/plugin/capability/authcap/externallogin" +) + +// CompleteCallback validates the signed callback state, exchanges the code +// for a verified identity, and hands the identity to the host external-login +// seam. Provisioning, tenant resolution, and token minting stay host-owned; +// the plugin only forwards the returned outcome to the controller. +func (s *serviceImpl) CompleteCallback(ctx context.Context, in CallbackInput) (*CallbackOutput, error) { + code := strings.TrimSpace(in.Code) + state := strings.TrimSpace(in.State) + if code == "" { + return nil, bizerr.WrapCode(gerror.New("oidc discord: callback code is empty"), CodeCallbackCodeRequired) + } + if state == "" { + return nil, bizerr.WrapCode(gerror.New("oidc discord: callback state is empty"), CodeCallbackStateMismatch) + } + if s.stateCodec == nil { + return nil, bizerr.WrapCode(gerror.New("oidc discord: state codec is missing"), CodeCallbackStateMismatch) + } + // Re-resolve settings per request so admin edits apply without a restart. + config := s.resolveConfig(ctx) + statePayload, err := s.stateCodec.Decode(ctx, state, config.ClientSecret) + if err != nil { + return nil, err + } + if s.externalLoginSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc discord: external-login service is unavailable"), + CodeExternalLoginUnavailable, + ) + } + identity, err := s.verifier.Verify(ctx, code, config.RedirectURL) + if err != nil { + return nil, err + } + if identity == nil || strings.TrimSpace(identity.Subject) == "" { + return nil, bizerr.WrapCode( + gerror.New("oidc discord: verified identity is missing subject"), + CodeIdentityVerifyFailed, + ) + } + logger.Infof( + ctx, + "linapro-oidc-discord callback verified provider=%s subject=%s email=%s", + Provider, + identity.Subject, + identity.Email, + ) + loginOut, err := s.externalLoginSvc.LoginByVerifiedIdentity(ctx, externallogin.LoginInput{ + Provider: Provider, + Subject: identity.Subject, + Email: identity.Email, + DisplayName: identity.DisplayName, + }) + if err != nil { + return nil, bizerr.WrapCode(err, CodeExternalLoginFailed) + } + if loginOut == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc discord: external-login returned nil outcome"), + CodeExternalLoginFailed, + ) + } + return &CallbackOutput{ + AccessToken: loginOut.AccessToken, + RefreshToken: loginOut.RefreshToken, + PreToken: loginOut.PreToken, + TenantCandidates: loginOut.TenantCandidates, + StateKey: statePayload.StateKey, + }, nil +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_code.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_code.go new file mode 100644 index 00000000..145dea89 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_code.go @@ -0,0 +1,61 @@ +// This file defines linapro-oidc-discord business error codes and runtime i18n +// metadata for the Discord OIDC login orchestration. + +package oauth + +import ( + "github.com/gogf/gf/v2/errors/gcode" + + "lina-core/pkg/bizerr" +) + +var ( + // CodeConfigMissing reports that the plugin-owned Discord client configuration is missing. + CodeConfigMissing = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_CONFIG_MISSING", + "Discord OIDC client configuration is missing", + gcode.CodeInvalidConfiguration, + ) + // CodeStateGenerateFailed reports that generating one CSRF state value failed. + CodeStateGenerateFailed = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_STATE_GENERATE_FAILED", + "Failed to generate OIDC state value", + gcode.CodeInternalError, + ) + // CodeCallbackCodeRequired reports that the Discord callback did not carry an authorization code. + CodeCallbackCodeRequired = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_CALLBACK_CODE_REQUIRED", + "Authorization code is required", + gcode.CodeInvalidParameter, + ) + // CodeCallbackStateMismatch reports that the callback state value did not match the expected value. + CodeCallbackStateMismatch = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_CALLBACK_STATE_MISMATCH", + "OIDC state value mismatch", + gcode.CodeSecurityReason, + ) + // CodeIdentityVerifyFailed reports that exchanging the code for a verified identity failed. + CodeIdentityVerifyFailed = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_IDENTITY_VERIFY_FAILED", + "Failed to verify Discord identity", + gcode.CodeInternalError, + ) + // CodeEmailNotVerified reports that the Discord account email is not verified. + CodeEmailNotVerified = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_EMAIL_NOT_VERIFIED", + "Discord account email is not verified", + gcode.CodeNotAuthorized, + ) + // CodeExternalLoginUnavailable reports that the host external-login service is missing. + CodeExternalLoginUnavailable = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_EXTERNAL_LOGIN_UNAVAILABLE", + "External-login service is unavailable", + gcode.CodeInvalidOperation, + ) + // CodeExternalLoginFailed reports that the host external-login exchange failed. + CodeExternalLoginFailed = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_EXTERNAL_LOGIN_FAILED", + "Discord external login failed", + gcode.CodeInternalError, + ) +) diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_config.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_config.go new file mode 100644 index 00000000..405d772c --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_config.go @@ -0,0 +1,69 @@ +// oauth_config.go defines the plugin-owned Discord OIDC client configuration +// used by the reference login flow. The configuration is intentionally kept +// as a plain value struct so it can be sourced from the host plugin config +// section, a plugin manifest file, or a startup override without pulling in +// runtime dependencies here. + +package oauth + +// Config carries the plugin-owned Discord OIDC client credentials and the +// OAuth endpoints the plugin talks to. Real deployments load this from the +// plugin configuration source chain; the reference implementation ships with +// placeholder values so the surrounding integration flow can be exercised +// end-to-end without a live Discord application. +type Config struct { + // ClientID is the Discord OAuth 2.0 client ID issued by the Discord + // Developer Portal. + ClientID string + // ClientSecret is the Discord OAuth 2.0 client secret paired with ClientID. + ClientSecret string + // RedirectURL is the callback URL registered with Discord. It must resolve + // to the plugin callback route so Discord routes the browser back through + // the plugin's own controller. + RedirectURL string + // AuthorizeURL is the Discord OAuth authorize endpoint. + AuthorizeURL string + // TokenURL is the Discord OAuth token endpoint. + TokenURL string + // UserInfoURL is the Discord OIDC userinfo endpoint used to project the + // verified identity into a stable subject, email, and display name. + UserInfoURL string + // Scopes are the OAuth scopes the plugin requests. The reference flow + // requests identify and email so it can build the verified identity DTO + // the host expects. + Scopes []string + // LoginReturnPath is the SPA path the callback controller redirects to + // after the host external-login exchange completes. The host issues the + // tokens or pre-token that must reach the SPA. + LoginReturnPath string +} + +// defaultConfig returns the reference-implementation configuration. The +// endpoints are the well-known Discord OAuth 2.0 URLs; the client credentials +// are intentionally placeholder values that a real deployment must override +// through the plugin configuration source chain. +func defaultConfig() Config { + return Config{ + ClientID: "REPLACE_ME_DISCORD_CLIENT_ID", + ClientSecret: "REPLACE_ME_DISCORD_CLIENT_SECRET", + // RedirectURL is intentionally empty: the OAuth service derives the + // callback URL from the live request host unless the admin overrides + // it through the settings page. + RedirectURL: "", + AuthorizeURL: "https://discord.com/oauth2/authorize", + TokenURL: "https://discord.com/api/oauth2/token", + UserInfoURL: "https://discord.com/api/users/@me", + Scopes: []string{"identify", "email"}, + // The default workspace serves the SPA under /admin/ with a hash + // router, so the callback outcome query must live inside the hash + // fragment for vue-router to expose it through route.query. + LoginReturnPath: "/admin/#/auth/login", + } +} + +// DefaultConfig returns one exported copy of the reference-implementation +// configuration so wiring code can start from the reference values and +// override individual fields as needed. +func DefaultConfig() Config { + return defaultConfig() +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_return_path.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_return_path.go new file mode 100644 index 00000000..ccaaf69f --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_return_path.go @@ -0,0 +1,14 @@ +// oauth_return_path.go exposes the configured SPA login return path so the +// callback controller can compose the outbound redirect URL without carrying +// its own copy of the plugin configuration. + +package oauth + +// LoginReturnPath returns the SPA path the callback controller redirects to +// after the host external-login exchange completes. +func (s *serviceImpl) LoginReturnPath() string { + if s == nil || s.configResolver == nil { + return "" + } + return s.configResolver.config.LoginReturnPath +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_state.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_state.go new file mode 100644 index 00000000..331f39e0 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_state.go @@ -0,0 +1,115 @@ +// oauth_state.go implements the self-contained HMAC-signed OAuth state used +// by the Discord OIDC login flow. The state token embeds the optional business +// state key, a random nonce, and an absolute expiry, and is signed with a key +// derived from the client secret. Because the token is self-validating, the +// flow does not depend on browser cookies surviving the cross-site round trip +// through Discord, which removes an entire class of SameSite/proxy cookie +// failures. + +package oauth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// stateTTL bounds the OAuth state lifetime to mitigate replay attacks. +const stateTTL = 10 * time.Minute + +// stateNonceByteSize is the number of random bytes bound to one authorize +// round trip. +const stateNonceByteSize = 16 + +// stateMACKeyPrefix scopes the HMAC key to this plugin so a signature can +// never be replayed against another plugin's flow. +const stateMACKeyPrefix = "linapro-oidc-discord::" + +// StatePayload holds the decoded contents of one OAuth state token. +type StatePayload struct { + // StateKey carries the optional business state key used by the SSO + // delivery rules. + StateKey string `json:"stateKey"` + // Nonce is a random value bound to one authorization round trip. + Nonce string `json:"nonce"` + // ExpiresAt is the absolute unix-second deadline beyond which the state + // is rejected. + ExpiresAt int64 `json:"expiresAt"` +} + +// StateCodec encodes and validates self-contained OAuth state tokens. The +// interface keeps the orchestration testable with a deterministic codec. +type StateCodec interface { + // Encode produces one signed state token embedding the business state key. + Encode(ctx context.Context, stateKey string, clientSecret string) (string, error) + // Decode verifies the signature and expiry and returns the payload. + Decode(ctx context.Context, state string, clientSecret string) (StatePayload, error) +} + +// hmacStateCodec is the production HMAC-SHA256 state codec. +type hmacStateCodec struct{} + +// NewHMACStateCodec returns the production self-contained state codec. +func NewHMACStateCodec() StateCodec { + return &hmacStateCodec{} +} + +// Encode serializes and signs one state payload. +func (c *hmacStateCodec) Encode(_ context.Context, stateKey string, clientSecret string) (string, error) { + nonceBuffer := make([]byte, stateNonceByteSize) + if _, err := rand.Read(nonceBuffer); err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc discord: state entropy read failed"), CodeStateGenerateFailed) + } + payload := StatePayload{ + StateKey: strings.TrimSpace(stateKey), + Nonce: base64.RawURLEncoding.EncodeToString(nonceBuffer), + ExpiresAt: time.Now().Add(stateTTL).Unix(), + } + raw, err := json.Marshal(payload) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc discord: encode state payload failed"), CodeStateGenerateFailed) + } + encoded := base64.RawURLEncoding.EncodeToString(raw) + return encoded + "." + computeStateMAC(encoded, clientSecret), nil +} + +// Decode verifies the HMAC signature and expiry of one state token. +func (c *hmacStateCodec) Decode(_ context.Context, state string, clientSecret string) (StatePayload, error) { + parts := strings.SplitN(strings.TrimSpace(state), ".", 2) + if len(parts) != 2 { + return StatePayload{}, bizerr.WrapCode(gerror.New("oidc discord: state token is malformed"), CodeCallbackStateMismatch) + } + expected := computeStateMAC(parts[0], clientSecret) + if !hmac.Equal([]byte(parts[1]), []byte(expected)) { + return StatePayload{}, bizerr.WrapCode(gerror.New("oidc discord: state signature mismatch"), CodeCallbackStateMismatch) + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return StatePayload{}, bizerr.WrapCode(gerror.Wrap(err, "oidc discord: decode state payload failed"), CodeCallbackStateMismatch) + } + var payload StatePayload + if err = json.Unmarshal(raw, &payload); err != nil { + return StatePayload{}, bizerr.WrapCode(gerror.Wrap(err, "oidc discord: parse state payload failed"), CodeCallbackStateMismatch) + } + if payload.ExpiresAt > 0 && time.Now().Unix() > payload.ExpiresAt { + return StatePayload{}, bizerr.WrapCode(gerror.New("oidc discord: state has expired"), CodeCallbackStateMismatch) + } + return payload, nil +} + +// computeStateMAC derives one HMAC-SHA256 signature scoped to this plugin. +func computeStateMAC(encodedPayload string, clientSecret string) string { + mac := hmac.New(sha256.New, []byte(stateMACKeyPrefix+clientSecret)) + mac.Write([]byte(encodedPayload)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier.go new file mode 100644 index 00000000..65457170 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier.go @@ -0,0 +1,71 @@ +// oauth_verifier.go defines the identity-verifier contract the Discord OIDC +// login flow depends on plus a reference stub implementation. In a real +// deployment the plugin swaps in an HTTP-backed verifier that talks to the +// Discord token and userinfo endpoints; the stub is intentionally lightweight +// so the surrounding orchestration can be exercised end-to-end without a +// live Discord application. + +package oauth + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// VerifiedIdentity is the neutral projection the plugin submits to the host +// external-login seam after a successful Discord callback exchange. +type VerifiedIdentity struct { + // Subject is the immutable Discord-issued user identifier ("id"). + Subject string + // Email is the Discord account email captured for audit and hook context. + Email string + // DisplayName is the Discord user global name or username captured for + // audit and hook context. + DisplayName string +} + +// IdentityVerifier exchanges the OAuth authorization code for a verified +// identity. The reference stub short-circuits the exchange with a deterministic +// verified identity so the surrounding orchestration can be exercised without +// contacting Discord. The production verifier performs the token exchange and +// userinfo fetch inside its Verify method. +type IdentityVerifier interface { + // Verify exchanges the supplied authorization code for a verified identity + // obtained from Discord. The redirectURL parameter is the same value the + // authorize URL was built with so token exchange can echo it back to + // Discord. Returning bizerr.CodeIdentityVerifyFailed maps to a stable + // client error at the controller boundary. + Verify(ctx context.Context, code string, redirectURL string) (*VerifiedIdentity, error) +} + +// stubIdentityVerifier is the deterministic identity verifier used by unit +// tests to exercise the orchestration without contacting Discord. Production +// wiring uses NewHTTPIdentityVerifier. +type stubIdentityVerifier struct{} + +// NewStubIdentityVerifier returns the deterministic test verifier that echoes +// the OAuth code as a Discord subject. It must never be wired in production; +// plugin.go wires NewHTTPIdentityVerifier instead. +func NewStubIdentityVerifier() IdentityVerifier { + return &stubIdentityVerifier{} +} + +// Verify projects the OAuth code into a deterministic verified identity so +// unit tests can exercise the downstream host external-login wiring without a +// live Discord application. It rejects blank codes with a stable business +// error. +func (v *stubIdentityVerifier) Verify(_ context.Context, code string, _ string) (*VerifiedIdentity, error) { + trimmed := strings.TrimSpace(code) + if trimmed == "" { + return nil, bizerr.WrapCode(gerror.New("oidc discord: empty authorization code"), CodeIdentityVerifyFailed) + } + return &VerifiedIdentity{ + Subject: "discord-stub-subject-" + trimmed, + Email: "stub-user+" + trimmed + "@example.com", + DisplayName: "Discord Reference User", + }, nil +} diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier_http.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier_http.go new file mode 100644 index 00000000..de1371d0 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_verifier_http.go @@ -0,0 +1,221 @@ +// oauth_verifier_http.go implements the production identity verifier: it +// exchanges the OAuth authorization code against Discord's token endpoint and +// fetches the verified identity from Discord's /users/@me endpoint. Outbound +// requests are bounded by a timeout, error bodies are truncated before +// logging so tokens never leak, and unverified email addresses are rejected +// before the identity reaches the host external-login seam. + +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" +) + +// httpTimeout bounds each outbound HTTP request to Discord. +const httpTimeout = 15 * time.Second + +// errorBodyLimit caps how much of an error response body is embedded into +// error messages so large payloads and tokens never leak into logs. +const errorBodyLimit = 512 + +// SettingsSource supplies the current OAuth client configuration at request +// time so admin edits apply without a restart. +type SettingsSource interface { + // ResolveConfig returns the effective OAuth configuration for one request. + ResolveConfig(ctx context.Context) Config +} + +// httpIdentityVerifier is the production verifier backed by Discord's token +// and user endpoints. +type httpIdentityVerifier struct { + // httpClient performs outbound requests to Discord with a bounded timeout. + httpClient *http.Client + // settings supplies client credentials and endpoints per request. + settings SettingsSource +} + +// NewHTTPIdentityVerifier returns the production identity verifier that +// performs the real Discord code exchange and user fetch. The settings source +// is consulted on every Verify call so credential rotations apply without +// restarting the host. +func NewHTTPIdentityVerifier(settings SettingsSource) IdentityVerifier { + return &httpIdentityVerifier{ + httpClient: &http.Client{Timeout: httpTimeout}, + settings: settings, + } +} + +// Verify exchanges the authorization code for a Discord access token, fetches +// the user claims, enforces email verification, and projects the result into +// the neutral VerifiedIdentity shape. +func (v *httpIdentityVerifier) Verify(ctx context.Context, code string, redirectURL string) (*VerifiedIdentity, error) { + trimmedCode := strings.TrimSpace(code) + if trimmedCode == "" { + return nil, bizerr.WrapCode(gerror.New("oidc discord: empty authorization code"), CodeIdentityVerifyFailed) + } + if v == nil || v.settings == nil { + return nil, bizerr.WrapCode(gerror.New("oidc discord: verifier settings source is missing"), CodeConfigMissing) + } + config := v.settings.ResolveConfig(ctx) + accessToken, err := v.exchangeCode(ctx, config, redirectURL, trimmedCode) + if err != nil { + return nil, err + } + return v.fetchUserIdentity(ctx, config, accessToken) +} + +// exchangeCode trades one authorization code for a Discord access token. +func (v *httpIdentityVerifier) exchangeCode(ctx context.Context, config Config, redirectURL string, code string) (string, error) { + clientID := strings.TrimSpace(config.ClientID) + clientSecret := strings.TrimSpace(config.ClientSecret) + if clientID == "" || clientSecret == "" { + return "", bizerr.WrapCode(gerror.New("oidc discord: client credentials are not configured"), CodeConfigMissing) + } + resolvedRedirect := strings.TrimSpace(redirectURL) + if resolvedRedirect == "" { + resolvedRedirect = strings.TrimSpace(config.RedirectURL) + } + if resolvedRedirect == "" { + return "", bizerr.WrapCode(gerror.New("oidc discord: redirect url is not configured"), CodeConfigMissing) + } + form := url.Values{} + form.Set("code", code) + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + form.Set("redirect_uri", resolvedRedirect) + form.Set("grant_type", "authorization_code") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc discord: build token request failed"), CodeIdentityVerifyFailed) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := v.httpClient.Do(req) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc discord: call token endpoint failed"), CodeIdentityVerifyFailed) + } + defer closeResponseBody(ctx, resp, "discord oauth token endpoint") + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc discord: read token response failed"), CodeIdentityVerifyFailed) + } + if resp.StatusCode != http.StatusOK { + return "", bizerr.WrapCode( + gerror.Newf("oidc discord: token endpoint returned status %d: %s", resp.StatusCode, truncate(string(body), errorBodyLimit)), + CodeIdentityVerifyFailed, + ) + } + var tokenResp struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err = json.Unmarshal(body, &tokenResp); err != nil { + return "", bizerr.WrapCode(gerror.Wrap(err, "oidc discord: decode token response failed"), CodeIdentityVerifyFailed) + } + if tokenResp.Error != "" { + return "", bizerr.WrapCode( + gerror.Newf("oidc discord: token endpoint error: %s: %s", tokenResp.Error, tokenResp.ErrorDesc), + CodeIdentityVerifyFailed, + ) + } + if strings.TrimSpace(tokenResp.AccessToken) == "" { + return "", bizerr.WrapCode(gerror.New("oidc discord: token endpoint returned empty access token"), CodeIdentityVerifyFailed) + } + return tokenResp.AccessToken, nil +} + +// fetchUserIdentity retrieves the Discord user claims and enforces the +// verified email requirement before projecting the identity. +func (v *httpIdentityVerifier) fetchUserIdentity(ctx context.Context, config Config, accessToken string) (*VerifiedIdentity, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.UserInfoURL, nil) + if err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc discord: build user request failed"), CodeIdentityVerifyFailed) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := v.httpClient.Do(req) + if err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc discord: call user endpoint failed"), CodeIdentityVerifyFailed) + } + defer closeResponseBody(ctx, resp, "discord oauth user endpoint") + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc discord: read user response failed"), CodeIdentityVerifyFailed) + } + if resp.StatusCode != http.StatusOK { + return nil, bizerr.WrapCode( + gerror.Newf("oidc discord: user endpoint returned status %d: %s", resp.StatusCode, truncate(string(body), errorBodyLimit)), + CodeIdentityVerifyFailed, + ) + } + var user struct { + ID string `json:"id"` + Username string `json:"username"` + GlobalName string `json:"global_name"` + Email string `json:"email"` + Verified bool `json:"verified"` + } + if err = json.Unmarshal(body, &user); err != nil { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc discord: decode user response failed"), CodeIdentityVerifyFailed) + } + if strings.TrimSpace(user.ID) == "" || strings.TrimSpace(user.Email) == "" { + return nil, bizerr.WrapCode(gerror.New("oidc discord: user response missing id or email"), CodeIdentityVerifyFailed) + } + if !user.Verified { + return nil, bizerr.WrapCode( + gerror.New("oidc discord: account email is not verified by Discord"), + CodeEmailNotVerified, + ) + } + displayName := strings.TrimSpace(user.GlobalName) + if displayName == "" { + displayName = user.Username + } + return &VerifiedIdentity{ + Subject: user.ID, + Email: user.Email, + DisplayName: displayName, + }, nil +} + +// SetHTTPClient overrides the outbound HTTP client. Intended for tests that +// need to intercept Discord traffic. +func (v *httpIdentityVerifier) SetHTTPClient(client *http.Client) { + if client != nil { + v.httpClient = client + } +} + +// truncate trims long strings used in error messages so secrets and large +// payloads do not leak into logs. +func truncate(value string, limit int) string { + if len(value) <= limit { + return value + } + return fmt.Sprintf("%s...(truncated)", value[:limit]) +} + +// closeResponseBody closes resp.Body once the caller has finished reading it +// and logs any close-time error against the supplied context so error returns +// are never silently discarded. +func closeResponseBody(ctx context.Context, resp *http.Response, endpointLabel string) { + if resp == nil || resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + logger.Warningf(ctx, "%s close response body failed err=%v", endpointLabel, err) + } +} diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings.go b/linapro-oidc-discord/backend/internal/service/settings/settings.go new file mode 100644 index 00000000..afb65391 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings.go @@ -0,0 +1,132 @@ +// Package settings implements the linapro-oidc-discord admin settings service. +// It persists Discord OIDC client credentials and the redirect URL through the +// host sys_config seam, and exposes both a masked projection for the admin +// settings page and a raw Snapshot used by the OAuth login orchestration at +// request time. +package settings + +import ( + "context" + + "lina-core/pkg/plugin/capability/hostconfigcap" +) + +// Plugin-scoped sys_config keys owned by the linapro-oidc-discord plugin. +// Rows are created on first save through the host SysConfig seam at platform +// tenant scope (`tenant_id = 0`). +const ( + // ConfigKeyClientID is the sys_config key holding the Discord client ID. + ConfigKeyClientID hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.client_id" + // ConfigKeyClientSecret is the sys_config key holding the Discord client secret. + ConfigKeyClientSecret hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.client_secret" + // ConfigKeyRedirectURL is the sys_config key holding the Discord redirect URL. + ConfigKeyRedirectURL hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.redirect_url" + // ConfigKeyEnableBackendRedirect is the sys_config key holding the SSO + // token-delivery enablement flag ("1" enabled, anything else disabled). + ConfigKeyEnableBackendRedirect hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.enable_backend_redirect" + // ConfigKeyDefaultBackendRedirect is the sys_config key holding the SPA + // landing path used after a normal (non-SSO) external login. + ConfigKeyDefaultBackendRedirect hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.default_backend_redirect" + // ConfigKeyBackendRedirects is the sys_config key holding the JSON object + // that maps business state keys to third-party SSO receiver URLs. + ConfigKeyBackendRedirects hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.backend_redirects" +) + +// SecretMask is the fixed indicator returned to the client whenever a client +// secret is currently stored. It intentionally does not embed characters from +// the real secret so the projection carries no plaintext material. +const SecretMask = "************" + +// Snapshot describes the resolved settings values used by the OAuth login +// flow. Fields hold empty strings when unset; the OAuth service is responsible +// for layering safe defaults on top of unset fields. +type Snapshot struct { + // ClientID is the raw stored Discord client ID. + ClientID string + // ClientSecret is the raw stored Discord client secret. Callers must never + // project this value to a public API response. + ClientSecret string + // RedirectURL is the raw stored Discord redirect URL. + RedirectURL string + // EnableBackendRedirect reports whether SSO token delivery to third-party + // receiver URLs is enabled. + EnableBackendRedirect bool + // DefaultBackendRedirect is the SPA landing path used after a normal + // (non-SSO) external login. Empty keeps the host default landing. + DefaultBackendRedirect string + // BackendRedirects is the raw JSON object mapping business state keys to + // third-party SSO receiver URLs. + BackendRedirects string +} + +// Projection is the masked settings projection returned to the admin settings +// page. It never carries the raw client secret; ClientSecretMasked is either +// SecretMask or an empty string, and ClientSecretConfigured reflects whether a +// non-empty raw secret is currently persisted. +type Projection struct { + // ClientID is the raw stored Discord client ID. + ClientID string + // ClientSecretMasked reports SecretMask when a stored secret exists and an + // empty string when no secret is stored yet. + ClientSecretMasked string + // ClientSecretConfigured reports whether a non-empty raw secret is stored. + ClientSecretConfigured bool + // RedirectURL is the raw stored Discord redirect URL. + RedirectURL string + // EnableBackendRedirect reports whether SSO token delivery is enabled. + EnableBackendRedirect bool + // DefaultBackendRedirect is the SPA landing path after a normal login. + DefaultBackendRedirect string + // BackendRedirects is the raw JSON object of state key to receiver URL. + BackendRedirects string +} + +// SaveInput carries the caller-supplied settings values submitted through the +// admin settings API. An empty ClientSecret or a value equal to SecretMask +// signals that the previously stored secret must be kept. +type SaveInput struct { + // ClientID replaces the stored client ID. + ClientID string + // ClientSecret replaces the stored client secret unless empty or masked. + ClientSecret string + // RedirectURL replaces the stored redirect URL. + RedirectURL string + // EnableBackendRedirect replaces the stored SSO delivery enablement flag. + EnableBackendRedirect bool + // DefaultBackendRedirect replaces the stored SPA landing path. + DefaultBackendRedirect string + // BackendRedirects replaces the stored state-key-to-receiver JSON object. + // A non-empty value must parse as a JSON object of string values. + BackendRedirects string +} + +// Service defines the linapro-oidc-discord settings service surface used by +// both the admin settings HTTP API and the OAuth login orchestration. +type Service interface { + // Get returns the masked projection consumed by the admin settings page. + Get(ctx context.Context) (*Projection, error) + // Save persists the caller-supplied settings values, honoring the + // "empty or masked secret keeps existing" contract. + Save(ctx context.Context, in SaveInput) (*Projection, error) + // Load returns the raw Snapshot consumed by the OAuth login flow. The + // snapshot must be re-read on every OAuth request so admin edits take + // effect without a restart. + Load(ctx context.Context) (*Snapshot, error) +} + +// Interface compliance assertion for the default settings service implementation. +var _ Service = (*serviceImpl)(nil) + +// serviceImpl reads and writes plugin-scoped settings through the host +// sys_config seam. It holds an explicit reference to the seam so wiring stays +// visible and testable. +type serviceImpl struct { + // sysConfigSvc is the host sys_config service used for persisted reads and writes. + sysConfigSvc hostconfigcap.SysConfigService +} + +// New creates and returns a new settings service instance bound to the +// supplied host sys_config service. +func New(sysConfigSvc hostconfigcap.SysConfigService) Service { + return &serviceImpl{sysConfigSvc: sysConfigSvc} +} diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings_code.go b/linapro-oidc-discord/backend/internal/service/settings/settings_code.go new file mode 100644 index 00000000..151f2b25 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_code.go @@ -0,0 +1,37 @@ +// settings_code.go declares linapro-oidc-discord settings business error codes. + +package settings + +import ( + "github.com/gogf/gf/v2/errors/gcode" + + "lina-core/pkg/bizerr" +) + +var ( + // CodeStorageUnavailable reports that the host sys_config service is unavailable. + CodeStorageUnavailable = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_SETTINGS_STORAGE_UNAVAILABLE", + "Settings storage service is unavailable", + gcode.CodeInvalidOperation, + ) + // CodeReadFailed reports that reading persisted settings failed. + CodeReadFailed = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_SETTINGS_READ_FAILED", + "Failed to read Discord OIDC settings", + gcode.CodeInternalError, + ) + // CodeSaveFailed reports that persisting settings values failed. + CodeSaveFailed = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_SETTINGS_SAVE_FAILED", + "Failed to save Discord OIDC settings", + gcode.CodeInternalError, + ) + // CodeRulesInvalid reports a backend-redirect rules payload that is not a + // JSON object of string receiver URLs. + CodeRulesInvalid = bizerr.MustDefine( + "PLUGIN_OIDC_DISCORD_SETTINGS_RULES_INVALID", + "Backend redirect rules must be a JSON object mapping state keys to receiver URLs", + gcode.CodeInvalidParameter, + ) +) diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings_read.go b/linapro-oidc-discord/backend/internal/service/settings/settings_read.go new file mode 100644 index 00000000..0274733c --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_read.go @@ -0,0 +1,97 @@ +// settings_read.go implements the read paths of the linapro-oidc-discord +// settings service. Reads project stored client secrets into a masked +// indicator so plaintext material never reaches the HTTP boundary. + +package settings + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/plugin/capability/capmodel" + "lina-core/pkg/plugin/capability/hostconfigcap" +) + +// allSettingsKeys returns the ordered list of sys_config keys the plugin owns. +// The order is preserved on batch reads so the projection is deterministic. +func allSettingsKeys() []hostconfigcap.SysConfigKey { + return []hostconfigcap.SysConfigKey{ + ConfigKeyClientID, + ConfigKeyClientSecret, + ConfigKeyRedirectURL, + ConfigKeyEnableBackendRedirect, + ConfigKeyDefaultBackendRedirect, + ConfigKeyBackendRedirects, + } +} + +// Get returns the masked settings projection consumed by the admin settings page. +func (s *serviceImpl) Get(ctx context.Context) (*Projection, error) { + snapshot, err := s.Load(ctx) + if err != nil { + return nil, err + } + return projectFromSnapshot(snapshot), nil +} + +// Load returns the raw settings snapshot consumed by the OAuth login flow. +// Missing sys_config rows are reported through the host BatchGet MissingIDs +// contract and treated as empty values so the OAuth service can layer its +// own defaults on top. +func (s *serviceImpl) Load(ctx context.Context) (*Snapshot, error) { + if s == nil || s.sysConfigSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("settings: host sys_config service is unavailable"), + CodeStorageUnavailable, + ) + } + result, err := s.sysConfigSvc.BatchGet(ctx, allSettingsKeys()) + if err != nil { + if bizerr.Is(err, capmodel.CodeCapabilityUnavailable) { + return nil, bizerr.WrapCode(err, CodeStorageUnavailable) + } + return nil, bizerr.WrapCode(err, CodeReadFailed) + } + snapshot := &Snapshot{} + if result != nil { + if info := result.Items[ConfigKeyClientID]; info != nil { + snapshot.ClientID = info.Value + } + if info := result.Items[ConfigKeyClientSecret]; info != nil { + snapshot.ClientSecret = info.Value + } + if info := result.Items[ConfigKeyRedirectURL]; info != nil { + snapshot.RedirectURL = info.Value + } + if info := result.Items[ConfigKeyEnableBackendRedirect]; info != nil { + snapshot.EnableBackendRedirect = info.Value == enabledFlagValue + } + if info := result.Items[ConfigKeyDefaultBackendRedirect]; info != nil { + snapshot.DefaultBackendRedirect = info.Value + } + if info := result.Items[ConfigKeyBackendRedirects]; info != nil { + snapshot.BackendRedirects = info.Value + } + } + return snapshot, nil +} + +// projectFromSnapshot masks the client secret before returning the projection. +func projectFromSnapshot(snapshot *Snapshot) *Projection { + projection := &Projection{} + if snapshot == nil { + return projection + } + projection.ClientID = snapshot.ClientID + projection.RedirectURL = snapshot.RedirectURL + projection.EnableBackendRedirect = snapshot.EnableBackendRedirect + projection.DefaultBackendRedirect = snapshot.DefaultBackendRedirect + projection.BackendRedirects = snapshot.BackendRedirects + if snapshot.ClientSecret != "" { + projection.ClientSecretConfigured = true + projection.ClientSecretMasked = SecretMask + } + return projection +} diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings_save.go b/linapro-oidc-discord/backend/internal/service/settings/settings_save.go new file mode 100644 index 00000000..9f190c1b --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_save.go @@ -0,0 +1,118 @@ +// settings_save.go implements the write path of the linapro-oidc-discord +// settings service. An empty or masked client secret keeps the previously +// stored value so admins can freely edit other fields without re-typing the +// secret. + +package settings + +import ( + "context" + "encoding/json" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" + "lina-core/pkg/plugin/capability/capmodel" + "lina-core/pkg/plugin/capability/hostconfigcap" +) + +// enabledFlagValue is the persisted representation of an enabled boolean flag. +const enabledFlagValue = "1" + +// Save persists the caller-supplied settings values through the host +// sys_config seam and returns the fresh masked projection so the caller can +// render the updated form state without a second read. +func (s *serviceImpl) Save(ctx context.Context, in SaveInput) (*Projection, error) { + if s == nil || s.sysConfigSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("settings: host sys_config service is unavailable"), + CodeStorageUnavailable, + ) + } + rules := strings.TrimSpace(in.BackendRedirects) + if err := validateBackendRedirects(rules); err != nil { + return nil, err + } + current, err := s.Load(ctx) + if err != nil { + return nil, err + } + nextSecret := resolveNextSecret(current.ClientSecret, in.ClientSecret) + if err := s.setValue(ctx, ConfigKeyClientID, strings.TrimSpace(in.ClientID)); err != nil { + return nil, err + } + if err := s.setValue(ctx, ConfigKeyRedirectURL, strings.TrimSpace(in.RedirectURL)); err != nil { + return nil, err + } + enableFlag := "" + if in.EnableBackendRedirect { + enableFlag = enabledFlagValue + } + if err := s.setValue(ctx, ConfigKeyEnableBackendRedirect, enableFlag); err != nil { + return nil, err + } + if err := s.setValue(ctx, ConfigKeyDefaultBackendRedirect, strings.TrimSpace(in.DefaultBackendRedirect)); err != nil { + return nil, err + } + if err := s.setValue(ctx, ConfigKeyBackendRedirects, rules); err != nil { + return nil, err + } + if nextSecret != current.ClientSecret { + if err := s.setValue(ctx, ConfigKeyClientSecret, nextSecret); err != nil { + return nil, err + } + } + logger.Infof(ctx, "linapro-oidc-discord settings saved clientIdSet=%t redirectUrlSet=%t secretSet=%t ssoEnabled=%t ruleSet=%t", + strings.TrimSpace(in.ClientID) != "", + strings.TrimSpace(in.RedirectURL) != "", + nextSecret != "", + in.EnableBackendRedirect, + rules != "", + ) + return projectFromSnapshot(&Snapshot{ + ClientID: strings.TrimSpace(in.ClientID), + ClientSecret: nextSecret, + RedirectURL: strings.TrimSpace(in.RedirectURL), + EnableBackendRedirect: in.EnableBackendRedirect, + DefaultBackendRedirect: strings.TrimSpace(in.DefaultBackendRedirect), + BackendRedirects: rules, + }), nil +} + +// validateBackendRedirects rejects rule payloads that are not a JSON object of +// string receiver URLs, so a malformed dictionary is surfaced at save time +// instead of silently breaking SSO delivery at login time. +func validateBackendRedirects(rules string) error { + if rules == "" { + return nil + } + var parsed map[string]string + if err := json.Unmarshal([]byte(rules), &parsed); err != nil { + return bizerr.WrapCode(err, CodeRulesInvalid) + } + return nil +} + +// setValue writes one plugin-scoped sys_config value and wraps host errors +// into the plugin's stable business error surface. +func (s *serviceImpl) setValue(ctx context.Context, key hostconfigcap.SysConfigKey, value string) error { + if err := s.sysConfigSvc.SetValue(ctx, key, value); err != nil { + if bizerr.Is(err, capmodel.CodeCapabilityUnavailable) { + return bizerr.WrapCode(err, CodeStorageUnavailable) + } + return bizerr.WrapCode(err, CodeSaveFailed) + } + return nil +} + +// resolveNextSecret honors the "empty or masked secret keeps existing" rule +// so admins can freely edit other fields without re-typing the client secret. +func resolveNextSecret(current string, submitted string) string { + trimmed := strings.TrimSpace(submitted) + if trimmed == "" || trimmed == SecretMask { + return current + } + return trimmed +} diff --git a/linapro-oidc-discord/backend/plugin.go b/linapro-oidc-discord/backend/plugin.go new file mode 100644 index 00000000..efe57386 --- /dev/null +++ b/linapro-oidc-discord/backend/plugin.go @@ -0,0 +1,121 @@ +// Package backend wires the linapro-oidc-discord source plugin into the host +// plugin registry. It declares the external-identity provider ID this plugin +// owns and registers the browser-facing OIDC login routes on the plugin's +// portal route group. +package backend + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/logger" + "lina-core/pkg/plugin/pluginhost" + pluginoidcdiscord "lina-plugin-linapro-oidc-discord" + loginctrl "lina-plugin-linapro-oidc-discord/backend/internal/controller/login" + settingsctrl "lina-plugin-linapro-oidc-discord/backend/internal/controller/settings" + oauthsvc "lina-plugin-linapro-oidc-discord/backend/internal/service/oauth" + settingssvc "lina-plugin-linapro-oidc-discord/backend/internal/service/settings" +) + +// pluginID is the immutable identifier declared by the embedded plugin.yaml. +const pluginID = "linapro-oidc-discord" + +// portalGroupPath is the browser-facing route group mounted outside the +// reserved /x API namespace. Login-start and callback routes must be public +// so unauthenticated browsers can complete the OIDC handshake. +const portalGroupPath = "/portal/linapro-oidc-discord" + +// init registers the linapro-oidc-discord source plugin, declares its +// external-identity provider ownership, and registers HTTP routes. +func init() { + plugin := pluginhost.NewDeclarations(pluginID) + plugin.Assets().UseEmbeddedFiles(pluginoidcdiscord.EmbeddedFiles) + // ProvideExternalIdentity establishes provider ownership so the host can + // reject LoginByVerifiedIdentity requests that claim providers this plugin + // does not own. This is an allowed top-level static registration panic. + if err := plugin.Providers().ProvideExternalIdentity(oauthsvc.Provider); err != nil { + panic(err) + } + if err := plugin.HTTP().RegisterRoutes( + pluginhost.ExtensionPointHTTPRouteRegister, + pluginhost.CallbackExecutionModeBlocking, + registerRoutes, + ); err != nil { + panic(err) + } + if err := pluginhost.RegisterSourcePlugin(plugin); err != nil { + panic(err) + } +} + +// registerRoutes registers the plugin HTTP routes: two PUBLIC browser-facing +// OIDC routes on the portal route group (login-start redirects to Discord, +// the callback exchanges the code with the host external-login seam), plus +// the PROTECTED admin settings API under the plugin API prefix guarded by +// Auth+Tenancy+Permission middlewares. +func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) error { + var ( + routes = registrar.Routes() + middlewares = routes.Middlewares() + services = registrar.Services() + ) + if services == nil { + return gerror.New("linapro-oidc-discord routes require host external-login capability") + } + authSvc := services.Auth() + if authSvc == nil || authSvc.ExternalLogin() == nil { + return gerror.New("linapro-oidc-discord routes require host external-login sub capability") + } + hostConfigSvc := services.HostConfig() + if hostConfigSvc == nil || hostConfigSvc.SysConfig() == nil { + return gerror.New("linapro-oidc-discord routes require host sys_config capability") + } + logger.Infof(ctx, "linapro-oidc-discord registering portal routes group=%s", portalGroupPath) + pluginSettingsSvc := settingssvc.New(hostConfigSvc.SysConfig()) + // The shared config resolver feeds both the login orchestration and the + // production HTTP identity verifier so they read identical request-time + // settings (client credentials, endpoints, derived callback URL). + configResolver := oauthsvc.NewConfigResolver(pluginSettingsSvc, oauthsvc.DefaultConfig()) + loginSvc := oauthsvc.New( + authSvc.ExternalLogin(), + configResolver, + oauthsvc.NewHTTPIdentityVerifier(configResolver), + oauthsvc.NewHMACStateCodec(), + ) + loginController := loginctrl.NewV1(loginSvc, pluginSettingsSvc) + settingsController := settingsctrl.NewV1(pluginSettingsSvc) + routes.Group(portalGroupPath, func(group pluginhost.RouteGroup) { + group.Middleware( + middlewares.NeverDoneCtx(), + middlewares.CORS(), + middlewares.RequestBodyLimit(), + middlewares.Ctx(), + ) + group.GET("/login", loginController.Start) + group.GET("/callback", loginController.Callback) + }) + routes.Group(routes.APIPrefix(), func(group pluginhost.RouteGroup) { + group.Group("/api/v1", func(group pluginhost.RouteGroup) { + group.Middleware( + middlewares.NeverDoneCtx(), + middlewares.HandlerResponse(), + middlewares.CORS(), + middlewares.RequestBodyLimit(), + middlewares.Ctx(), + ) + group.Group("/", func(group pluginhost.RouteGroup) { + group.Middleware( + middlewares.Auth(), + middlewares.Tenancy(), + middlewares.Permission(), + ) + group.Bind( + settingsController.GetSettings, + settingsController.SaveSettings, + ) + }) + }) + }) + return routes.Err() +} diff --git a/linapro-oidc-discord/frontend/pages/settings.vue b/linapro-oidc-discord/frontend/pages/settings.vue new file mode 100644 index 00000000..b2fe333b --- /dev/null +++ b/linapro-oidc-discord/frontend/pages/settings.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/linapro-oidc-discord/frontend/slots/auth.login.after/discord-login-entry.vue b/linapro-oidc-discord/frontend/slots/auth.login.after/discord-login-entry.vue new file mode 100644 index 00000000..01c26240 --- /dev/null +++ b/linapro-oidc-discord/frontend/slots/auth.login.after/discord-login-entry.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/linapro-oidc-discord/go.mod b/linapro-oidc-discord/go.mod new file mode 100644 index 00000000..d7f1555d --- /dev/null +++ b/linapro-oidc-discord/go.mod @@ -0,0 +1,40 @@ +module lina-plugin-linapro-oidc-discord + +go 1.25.0 + +require ( + github.com/gogf/gf/v2 v2.10.1 + lina-core v0.0.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace lina-core => ../../lina-core diff --git a/linapro-oidc-discord/go.sum b/linapro-oidc-discord/go.sum new file mode 100644 index 00000000..99ffaa59 --- /dev/null +++ b/linapro-oidc-discord/go.sum @@ -0,0 +1,85 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 h1:39+jbTenm7KBj4hO2C8ANAxVHpX/7OuRDs1VcGC9ylA= +github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0/go.mod h1:B0s0fVzn0W220E8UTpSGzrrGKsop5KcB90twBeLCiz0= +github.com/gogf/gf/v2 v2.10.1 h1:0Jf6+ij2Xr3QOh/XlZAPVEKlPmui50tfL14WWNAop/Y= +github.com/gogf/gf/v2 v2.10.1/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/linapro-oidc-discord/manifest/i18n/en-US/apidoc/plugin-api-main.json b/linapro-oidc-discord/manifest/i18n/en-US/apidoc/plugin-api-main.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/en-US/apidoc/plugin-api-main.json @@ -0,0 +1 @@ +{} diff --git a/linapro-oidc-discord/manifest/i18n/en-US/error.json b/linapro-oidc-discord/manifest/i18n/en-US/error.json new file mode 100644 index 00000000..55cdb408 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/en-US/error.json @@ -0,0 +1,42 @@ +{ + "error": { + "plugin": { + "oidc": { + "discord": { + "config": { + "missing": "Discord OIDC client configuration is missing" + }, + "state": { + "generate": { + "failed": "Failed to generate OIDC state value" + } + }, + "callback": { + "code": { + "required": "Authorization code is required" + }, + "state": { + "mismatch": "OIDC state value mismatch" + } + }, + "identity": { + "verify": { + "failed": "Failed to verify Discord identity" + } + }, + "email": { + "not": { + "verified": "Discord account email is not verified" + } + }, + "external": { + "login": { + "unavailable": "External-login service is unavailable", + "failed": "Discord external login failed" + } + } + } + } + } + } +} diff --git a/linapro-oidc-discord/manifest/i18n/en-US/plugin.json b/linapro-oidc-discord/manifest/i18n/en-US/plugin.json new file mode 100644 index 00000000..b7ed8f27 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/en-US/plugin.json @@ -0,0 +1,44 @@ +{ + "plugin": { + "linapro-oidc-discord": { + "description": "Reference source plugin that demonstrates Discord OIDC third-party login on the framework external-identity seam.", + "name": "Discord OIDC Login", + "login": { + "button": "Continue with Discord", + "callback": { + "error": "Discord login failed. Please try again.", + "unlinked": "This Discord account is not linked to any local user. Please contact the administrator." + } + }, + "settings": { + "title": "Discord OIDC Settings", + "description": "Configure the Discord OAuth 2.0 client used by the login button. The redirect URL must match the callback URL registered in the Discord Developer Portal.", + "clientIdLabel": "Client ID", + "clientIdPlaceholder": "Enter the Discord OAuth 2.0 client ID", + "clientSecretLabel": "Client Secret", + "clientSecretPlaceholder": "Enter the Discord OAuth 2.0 client secret", + "clientSecretKeepPlaceholder": "Leave unchanged to keep the stored secret", + "redirectUrlLabel": "Callback URL", + "redirectUrlPlaceholder": "https://your-host/portal/linapro-oidc-discord/callback", + "redirectUrlHint": "Fixed by the plugin. Copy this URL into the Redirects of your Discord application OAuth2 settings.", + "copyCallbackUrl": "Copy", + "save": "Save", + "saveSuccess": "Discord OIDC settings saved.", + "saveFailed": "Failed to save Discord OIDC settings.", + "loadFailed": "Failed to load Discord OIDC settings.", + "defaultRedirectLabel": "Workspace Landing Path", + "defaultRedirectPlaceholder": "/dashboard/analytics", + "enableSsoLabel": "Enable SSO token delivery", + "enableSsoHint": "When enabled, logins started with a matching state key deliver the tokens straight to the configured third-party receiver URL instead of the workspace.", + "rulesTitle": "SSO Delivery Rules", + "addRule": "Add Rule", + "ruleKeyPlaceholder": "State key", + "ruleUrlPlaceholder": "https://third-party.example.com/sso/receive", + "copyLoginUrl": "Copy Login URL", + "copied": "Login URL copied.", + "copyFailed": "Failed to copy the login URL.", + "deleteRule": "Delete" + } + } + } +} diff --git a/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json b/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json new file mode 100644 index 00000000..42e8620b --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json @@ -0,0 +1,91 @@ +{ + "plugins": { + "linapro_oidc_discord": { + "api": { + "settings": { + "v1": { + "GetSettingsReq": { + "meta": { + "dc": "返回持久化的 linapro-oidc-discord 设置供管理设置页使用。客户端密钥始终以掩码指示符返回;明文密钥不会发送给客户端。", + "summary": "查询 Discord OIDC 设置", + "tags": "Discord OIDC 登录" + }, + "schema": { + "dc": "返回持久化的 linapro-oidc-discord 设置供管理设置页使用。客户端密钥始终以掩码指示符返回;明文密钥不会发送给客户端。" + } + }, + "GetSettingsRes": { + "fields": { + "settings": { + "dc": "从 sys_config 读取的设置投影,客户端密钥已掩码" + } + } + }, + "SaveSettingsReq": { + "meta": { + "dc": "将 linapro-oidc-discord 设置持久化到 sys_config。空或掩码形式的客户端密钥保留原值;非空非掩码值将替换原值。", + "summary": "保存 Discord OIDC 设置", + "tags": "Discord OIDC 登录" + }, + "schema": { + "dc": "将 linapro-oidc-discord 设置持久化到 sys_config。空或掩码形式的客户端密钥保留原值;非空非掩码值将替换原值。" + }, + "fields": { + "clientId": { + "dc": "Discord OAuth 2.0 客户端 ID;空字符串清除已存值" + }, + "clientSecret": { + "dc": "Discord OAuth 2.0 客户端密钥;空字符串或掩码指示符保留原值,其他值替换原值" + }, + "redirectUrl": { + "dc": "在 Discord 注册的完整回调 URL;空字符串清除已存值" + }, + "enableBackendRedirect": { + "dc": "启用按 state key 匹配的第三方 SSO 令牌投递" + }, + "defaultBackendRedirect": { + "dc": "普通外部登录后的工作台落地页;空字符串保持宿主默认" + }, + "backendRedirects": { + "dc": "业务 state key 到第三方 SSO 接收地址的 JSON 对象;空字符串清除所有规则" + } + } + }, + "SaveSettingsRes": { + "fields": { + "settings": { + "dc": "保存生效后的最新设置投影,客户端密钥已掩码" + } + } + }, + "SettingsItem": { + "fields": { + "clientId": { + "dc": "Discord Developer Portal 签发的 Discord OAuth 2.0 客户端 ID" + }, + "clientSecretMasked": { + "dc": "客户端密钥掩码指示符;已存密钥返回固定掩码,未存返回空字符串。明文密钥永不返回。" + }, + "clientSecretConfigured": { + "dc": "当前是否已存储客户端密钥;UI 用它区分掩码值与未设置" + }, + "redirectUrl": { + "dc": "在 Discord 注册的完整回调 URL;必须解析到插件回调路由" + }, + "enableBackendRedirect": { + "dc": "是否已启用第三方 SSO 令牌投递" + }, + "defaultBackendRedirect": { + "dc": "普通外部登录后的工作台落地页" + }, + "backendRedirects": { + "dc": "业务 state key 到第三方 SSO 接收地址的 JSON 对象" + } + } + } + } + } + } + } + } +} diff --git a/linapro-oidc-discord/manifest/i18n/zh-CN/error.json b/linapro-oidc-discord/manifest/i18n/zh-CN/error.json new file mode 100644 index 00000000..1d419c9b --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/error.json @@ -0,0 +1,42 @@ +{ + "error": { + "plugin": { + "oidc": { + "discord": { + "config": { + "missing": "Discord OIDC 客户端配置缺失" + }, + "state": { + "generate": { + "failed": "生成 OIDC 状态值失败" + } + }, + "callback": { + "code": { + "required": "缺少授权码" + }, + "state": { + "mismatch": "OIDC 状态值不匹配" + } + }, + "identity": { + "verify": { + "failed": "校验 Discord 身份失败" + } + }, + "email": { + "not": { + "verified": "Discord 账号邮箱未通过验证" + } + }, + "external": { + "login": { + "unavailable": "外部登录服务不可用", + "failed": "Discord 外部登录失败" + } + } + } + } + } + } +} diff --git a/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json b/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json new file mode 100644 index 00000000..6ed50074 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json @@ -0,0 +1,44 @@ +{ + "plugin": { + "linapro-oidc-discord": { + "description": "演示如何在框架外部身份接缝上接入 Discord OIDC 第三方登录的参考源码插件。", + "name": "Discord OIDC 登录", + "login": { + "button": "使用 Discord 账号登录", + "callback": { + "error": "Discord 登录失败,请稍后重试。", + "unlinked": "该 Discord 账号尚未绑定本地用户,请联系管理员。" + } + }, + "settings": { + "title": "Discord OIDC 设置", + "description": "配置登录按钮使用的 Discord OAuth 2.0 客户端。回调 URL 必须与 Discord Developer Portal 中注册的回调地址一致。", + "clientIdLabel": "客户端 ID", + "clientIdPlaceholder": "请输入 Discord OAuth 2.0 客户端 ID", + "clientSecretLabel": "客户端密钥", + "clientSecretPlaceholder": "请输入 Discord OAuth 2.0 客户端密钥", + "clientSecretKeepPlaceholder": "留空则保留已存储的密钥", + "redirectUrlLabel": "回调 URL", + "redirectUrlPlaceholder": "https://your-host/portal/linapro-oidc-discord/callback", + "redirectUrlHint": "由插件固定,无需配置。复制此地址到 Discord 应用 OAuth2 设置的 Redirects 中。", + "copyCallbackUrl": "复制", + "save": "保存", + "saveSuccess": "Discord OIDC 设置已保存。", + "saveFailed": "保存 Discord OIDC 设置失败。", + "loadFailed": "加载 Discord OIDC 设置失败。", + "defaultRedirectLabel": "工作台落地页", + "defaultRedirectPlaceholder": "/dashboard/analytics", + "enableSsoLabel": "启用 SSO 令牌投递", + "enableSsoHint": "启用后,携带匹配 state key 发起的登录会把令牌直接投递到配置的第三方接收地址,而不进入工作台。", + "rulesTitle": "SSO 投递规则", + "addRule": "添加规则", + "ruleKeyPlaceholder": "State key", + "ruleUrlPlaceholder": "https://third-party.example.com/sso/receive", + "copyLoginUrl": "复制登录链接", + "copied": "登录链接已复制。", + "copyFailed": "复制登录链接失败。", + "deleteRule": "删除" + } + } + } +} diff --git a/linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql b/linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql new file mode 100644 index 00000000..abf18132 --- /dev/null +++ b/linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql @@ -0,0 +1,18 @@ +-- 001: linapro-oidc-discord plugin settings +-- 001:linapro-oidc-discord 插件设置 +-- Seeds the platform-level sys_config rows the plugin reads at request time. +-- 为插件在请求期读取的平台级 sys_config 行提供初始化数据。 + +-- ============================================================ +-- Plugin settings seed data: Discord OIDC client credentials, redirect URL, +-- and SSO token-delivery configuration +-- 插件设置初始化数据:Discord OIDC 客户端凭据、回调地址与 SSO 令牌投递配置 +-- ============================================================ +INSERT INTO sys_config ("tenant_id", "name", "key", "value", "is_builtin", "remark", "created_at", "updated_at") VALUES +(0, 'Discord OIDC-Client ID', 'plugin.linapro-oidc-discord.client_id', '', 1, 'Discord OAuth 2.0 client ID issued by the Discord Developer Portal; edit through the Discord OIDC settings page.', NOW(), NOW()), +(0, 'Discord OIDC-Client Secret', 'plugin.linapro-oidc-discord.client_secret', '', 1, 'Discord OAuth 2.0 client secret paired with the client ID; the settings page always returns a masked value.', NOW(), NOW()), +(0, 'Discord OIDC-Redirect URL', 'plugin.linapro-oidc-discord.redirect_url', '', 1, 'Fully-qualified callback URL registered with Discord; must resolve to /portal/linapro-oidc-discord/callback.', NOW(), NOW()), +(0, 'Discord OIDC-Enable SSO Delivery', 'plugin.linapro-oidc-discord.enable_backend_redirect', '', 1, 'SSO token-delivery enablement flag; "1" enables delivery to third-party receiver URLs matched by state key.', NOW(), NOW()), +(0, 'Discord OIDC-Workspace Landing Path', 'plugin.linapro-oidc-discord.default_backend_redirect', '', 1, 'Workspace landing path after a normal external login; empty keeps the host default landing.', NOW(), NOW()), +(0, 'Discord OIDC-SSO Delivery Rules', 'plugin.linapro-oidc-discord.backend_redirects', '', 1, 'JSON object mapping business state keys to third-party SSO receiver URLs.', NOW(), NOW()) +ON CONFLICT DO NOTHING; diff --git a/linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql b/linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql new file mode 100644 index 00000000..217a74a8 --- /dev/null +++ b/linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql @@ -0,0 +1,15 @@ +-- 001: linapro-oidc-discord plugin settings uninstall +-- 001:linapro-oidc-discord 插件设置卸载 +-- Removes the platform-level sys_config rows seeded during install. +-- 删除安装阶段创建的平台级 sys_config 行。 + +DELETE FROM sys_config +WHERE "tenant_id" = 0 + AND "key" IN ( + 'plugin.linapro-oidc-discord.client_id', + 'plugin.linapro-oidc-discord.client_secret', + 'plugin.linapro-oidc-discord.redirect_url', + 'plugin.linapro-oidc-discord.enable_backend_redirect', + 'plugin.linapro-oidc-discord.default_backend_redirect', + 'plugin.linapro-oidc-discord.backend_redirects' + ); diff --git a/linapro-oidc-discord/plugin.yaml b/linapro-oidc-discord/plugin.yaml new file mode 100644 index 00000000..321cf560 --- /dev/null +++ b/linapro-oidc-discord/plugin.yaml @@ -0,0 +1,114 @@ +# Plugin identifier, unique across the host and source plugin directories; use `kebab-case`. +# 插件唯一标识,必须在宿主和源码插件目录中保持唯一,并使用 `kebab-case` 命名风格。 +id: linapro-oidc-discord + +# Plugin display name used in plugin management pages, developer docs, and presentation. +# 插件显示名称,用于插件管理页面、开发文档和展示。 +name: Discord OIDC Login + +# Plugin semantic version; examples in this repo use the `v` prefix. +# 插件语义化版本号,当前示例统一使用带 `v` 前缀的写法。 +version: v0.1.0 + +# Plugin type enum. Allowed values: `source` is compiled with the host; `dynamic` is loaded as a runtime plugin artifact. +# 插件类型枚举。可选值:`source` 表示随宿主源码编译交付;`dynamic` 表示作为运行时动态插件产物加载。 +type: source + +# Plugin distribution governance enum. `managed` keeps this plugin explicitly governed through plugin management or `plugin.autoEnable`. Use `builtin` only for registered source plugins that host startup must install, enable, and safely upgrade. +# 插件分发治理枚举。`managed`表示本示例仍通过插件管理或`plugin.autoEnable`显式治理。只有需要宿主启动自动安装、启用和安全升级的已注册源码插件才使用`builtin`。 +distribution: managed + +# Multi-tenant scope enum. Allowed values: `platform_only` is platform-context only; `tenant_aware` is governed by tenant context. +# 多租户作用域枚举。可选值:`platform_only` 表示仅平台上下文可见和治理;`tenant_aware` 表示按租户上下文治理。 +scope_nature: platform_only + +# Multi-tenant support flag. `true` means the plugin supports tenant-level installation and provisioning governance. +# 多租户支持标记。`true` 表示插件支持租户级安装与开通治理。 +supports_multi_tenant: false + +# Default install mode enum. Allowed values: `global` enables one shared installation; `tenant_scoped` enables or disables per tenant. +# 默认安装模式枚举。可选值:`global` 表示全局统一安装启用;`tenant_scoped` 表示按租户独立启用或禁用。 +default_install_mode: global + +# Plugin description summarizing the capability boundary. +# 插件说明,概述插件的能力边界。 +description: Reference source plugin that demonstrates Discord OIDC third-party login on the framework external-identity seam. + +# Plugin author or owning team. +# 插件作者或归属团队。 +author: linapro + +# Plugin homepage or documentation URL for extra project, design, or external references. +# 插件主页或文档地址,用于补充项目介绍、设计文档或外部说明链接。 +homepage: https://example.com/lina/plugins/linapro-oidc-discord + +# Plugin license identifier. +# 插件许可证标识。 +license: Apache-2.0 + +# Plugin internationalization config. This follows the host i18n config shape; +# enabled plugins must ship manifest/i18n resources for the listed locales. +# 插件国际化配置。该配置与宿主 i18n 配置结构保持一致;启用后需提供对应语言资源。 +i18n: + enabled: true + default: en-US + locales: + - locale: en-US + nativeName: English + - locale: zh-CN + nativeName: 简体中文 + +# Plugin menu declaration list; the host syncs menus, button permissions, and route entries from it. +# 插件菜单声明列表,宿主按该列表同步菜单、按钮权限和路由入口。 +menus: + # Unique menu key; it must use the `plugin::` format. + # 菜单唯一键,必须使用 `plugin::` 格式。 + - key: plugin:linapro-oidc-discord:settings + # Parent menu key pointing to the host extension center catalog. + # 父级菜单键,指向宿主扩展中心目录。 + parent_key: extension + # Menu display name. + # 菜单显示名称。 + name: Discord OIDC 设置 + # Source plugin host route segment mounted into the governed dynamic route tree. + # 源码插件宿主内路由片段,会挂到插件治理后的动态路由树中。 + path: linapro-oidc-discord-settings + # Menu render component; the dynamic page shell hosts plugin pages. + # 菜单渲染组件,动态页壳用于承载插件页面。 + component: system/plugin/dynamic-page + # Permission code required to access this menu. + # 菜单访问权限标识。 + perms: linapro-oidc-discord:settings:view + # Menu icon using an Iconify icon name. + # 菜单图标,使用 Iconify 图标名称。 + icon: logos:discord-icon + # Menu type enum. Allowed values: `D` directory, `M` menu page, `B` button permission node. + # 菜单类型枚举。可选值:`D` 表示目录,`M` 表示菜单页面,`B` 表示按钮权限点。 + type: M + # Menu sort order; lower values appear earlier. + # 菜单排序值,数值越小越靠前。 + sort: 11 + # Menu remark stored in host menu governance data. + # 菜单备注,写入宿主菜单治理数据。 + remark: Discord OIDC 登录设置页 + # Unique button key mounted under the Discord OIDC settings menu. + # 按钮唯一键,挂载在 Discord OIDC 设置菜单下。 + - key: plugin:linapro-oidc-discord:settings-update + # Parent menu key pointing to the Discord OIDC settings menu. + # 父级菜单键,指向 Discord OIDC 设置菜单。 + parent_key: plugin:linapro-oidc-discord:settings + # Button display name. + # 按钮显示名称。 + name: Discord OIDC 设置保存 + # Button permission code. + # 按钮权限标识。 + perms: linapro-oidc-discord:settings:update + # Menu type enum. Allowed values: `D` directory, `M` menu page, `B` button permission node. + # 菜单类型枚举。可选值:`D` 表示目录,`M` 表示菜单页面,`B` 表示按钮权限点。 + type: B + # Button sort order; lower values appear earlier. + # 按钮排序值,数值越小越靠前。 + sort: 1 + # Button remark stored in host menu governance data. + # 按钮备注,写入宿主菜单治理数据。 + remark: Discord OIDC 设置保存权限 diff --git a/linapro-oidc-discord/plugin_embed.go b/linapro-oidc-discord/plugin_embed.go new file mode 100644 index 00000000..cf07a40d --- /dev/null +++ b/linapro-oidc-discord/plugin_embed.go @@ -0,0 +1,13 @@ +// Package pluginoidcdiscord is the source-plugin embedding entry for the +// linapro-oidc-discord reference plugin. It only owns the embedded filesystem +// binding so the backend package can register plugin.yaml, frontend slot +// components, and manifest i18n resources with the host at compile time. +package pluginoidcdiscord + +import "embed" + +// EmbeddedFiles contains the plugin manifest, frontend slot components, and +// manifest resources shipped with the linapro-oidc-discord source plugin. +// +//go:embed plugin.yaml frontend manifest +var EmbeddedFiles embed.FS From e510107b24dcbbe49d248a8620559efea61bf358 Mon Sep 17 00:00:00 2001 From: osindex Date: Wed, 8 Jul 2026 22:20:25 +0800 Subject: [PATCH 3/4] feat(oidc): add Google One Tap login and migrate settings seeding to hostconfig upsert --- .../backend/api/settings/v1/settings.go | 3 + .../backend/api/settings/v1/settings_save.go | 2 + .../controller/settings/settings_v1_save.go | 2 + .../internal/service/oauth/oauth_callback.go | 23 +++- .../internal/service/settings/settings.go | 11 ++ .../service/settings/settings_read.go | 5 + .../service/settings/settings_save.go | 8 ++ .../frontend/pages/settings.vue | 17 +++ .../manifest/i18n/en-US/plugin.json | 4 +- .../i18n/zh-CN/apidoc/plugin-api-main.json | 6 + .../manifest/i18n/zh-CN/plugin.json | 4 +- .../sql/001-linapro-oidc-discord-settings.sql | 18 --- .../001-linapro-oidc-discord-settings.sql | 15 --- .../backend/api/settings/v1/settings.go | 5 + .../backend/api/settings/v1/settings_save.go | 4 + .../internal/controller/login/login_onetap.go | 63 +++++++++ .../controller/settings/settings_v1_save.go | 4 + .../backend/internal/service/oauth/oauth.go | 11 ++ .../internal/service/oauth/oauth_callback.go | 23 +++- .../internal/service/oauth/oauth_code.go | 13 ++ .../service/oauth/oauth_idtoken_verifier.go | 98 ++++++++++++++ .../internal/service/oauth/oauth_jwks.go | 126 ++++++++++++++++++ .../internal/service/oauth/oauth_onetap.go | 91 +++++++++++++ .../internal/service/settings/settings.go | 21 +++ .../service/settings/settings_read.go | 10 ++ .../service/settings/settings_save.go | 16 +++ linapro-oidc-google/backend/plugin.go | 2 + .../frontend/pages/settings.vue | 91 +++++++++++++ linapro-oidc-google/go.mod | 1 + linapro-oidc-google/go.sum | 2 + .../manifest/i18n/en-US/error.json | 8 ++ .../manifest/i18n/en-US/plugin.json | 10 +- .../i18n/zh-CN/apidoc/plugin-api-main.json | 12 ++ .../manifest/i18n/zh-CN/error.json | 8 ++ .../manifest/i18n/zh-CN/plugin.json | 10 +- .../sql/001-linapro-oidc-google-settings.sql | 18 --- .../001-linapro-oidc-google-settings.sql | 15 --- 37 files changed, 702 insertions(+), 78 deletions(-) delete mode 100644 linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql delete mode 100644 linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql create mode 100644 linapro-oidc-google/backend/internal/controller/login/login_onetap.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_idtoken_verifier.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_jwks.go create mode 100644 linapro-oidc-google/backend/internal/service/oauth/oauth_onetap.go delete mode 100644 linapro-oidc-google/manifest/sql/001-linapro-oidc-google-settings.sql delete mode 100644 linapro-oidc-google/manifest/sql/uninstall/001-linapro-oidc-google-settings.sql diff --git a/linapro-oidc-discord/backend/api/settings/v1/settings.go b/linapro-oidc-discord/backend/api/settings/v1/settings.go index f72eebdb..930c7e58 100644 --- a/linapro-oidc-discord/backend/api/settings/v1/settings.go +++ b/linapro-oidc-discord/backend/api/settings/v1/settings.go @@ -25,4 +25,7 @@ type SettingsItem struct { DefaultBackendRedirect string `json:"defaultBackendRedirect" dc:"Workspace landing path used after a normal external login; empty keeps the host default" eg:"/dashboard/analytics"` // BackendRedirects is the JSON object mapping state keys to receiver URLs. BackendRedirects string `json:"backendRedirects" dc:"JSON object mapping business state keys to third-party SSO receiver URLs" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` + // AllowAutoProvision reports whether unlinked verified identities may be + // auto-provisioned as least-privilege platform users. + AllowAutoProvision bool `json:"allowAutoProvision" dc:"True when the host may auto-provision a least-privilege platform user for an unlinked verified Discord identity" eg:"false"` } diff --git a/linapro-oidc-discord/backend/api/settings/v1/settings_save.go b/linapro-oidc-discord/backend/api/settings/v1/settings_save.go index ce659216..5dfdcd9f 100644 --- a/linapro-oidc-discord/backend/api/settings/v1/settings_save.go +++ b/linapro-oidc-discord/backend/api/settings/v1/settings_save.go @@ -17,6 +17,8 @@ type SaveSettingsReq struct { DefaultBackendRedirect string `json:"defaultBackendRedirect" v:"max-length:512" dc:"Workspace landing path after a normal external login; empty string keeps the host default" eg:"/dashboard/analytics"` // BackendRedirects sets the state-key-to-receiver-URL JSON object. BackendRedirects string `json:"backendRedirects" v:"max-length:4096" dc:"JSON object mapping business state keys to third-party SSO receiver URLs; empty string clears all rules" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` + // AllowAutoProvision toggles host auto-provisioning for unlinked identities. + AllowAutoProvision bool `json:"allowAutoProvision" dc:"Allow the host to auto-provision a least-privilege platform user when a verified Discord identity has no linked local account" eg:"false"` } // SaveSettingsRes is the response for saving the linapro-oidc-discord settings. diff --git a/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go index 8e19d63c..e9eda617 100644 --- a/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go +++ b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go @@ -21,6 +21,7 @@ func (c *ControllerV1) SaveSettings(ctx context.Context, req *v1.SaveSettingsReq EnableBackendRedirect: req.EnableBackendRedirect, DefaultBackendRedirect: req.DefaultBackendRedirect, BackendRedirects: req.BackendRedirects, + AllowAutoProvision: req.AllowAutoProvision, }) if err != nil { return nil, err @@ -41,5 +42,6 @@ func projectSettingsItem(projection *settingssvc.Projection) *v1.SettingsItem { EnableBackendRedirect: projection.EnableBackendRedirect, DefaultBackendRedirect: projection.DefaultBackendRedirect, BackendRedirects: projection.BackendRedirects, + AllowAutoProvision: projection.AllowAutoProvision, } } diff --git a/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go b/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go index 7a110d1b..c2e8d2b2 100644 --- a/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go @@ -18,6 +18,20 @@ import ( "lina-core/pkg/plugin/capability/authcap/externallogin" ) +// resolveAllowAutoProvision reads the admin-controlled auto-provision flag at +// request time so settings edits apply without a restart. A missing resolver +// or settings load failure keeps auto-provisioning disabled fail-closed. +func (s *serviceImpl) resolveAllowAutoProvision(ctx context.Context) bool { + if s == nil || s.configResolver == nil || s.configResolver.settingsSvc == nil { + return false + } + snapshot, err := s.configResolver.settingsSvc.Load(ctx) + if err != nil || snapshot == nil { + return false + } + return snapshot.AllowAutoProvision +} + // CompleteCallback validates the signed callback state, exchanges the code // for a verified identity, and hands the identity to the host external-login // seam. Provisioning, tenant resolution, and token minting stay host-owned; @@ -64,10 +78,11 @@ func (s *serviceImpl) CompleteCallback(ctx context.Context, in CallbackInput) (* identity.Email, ) loginOut, err := s.externalLoginSvc.LoginByVerifiedIdentity(ctx, externallogin.LoginInput{ - Provider: Provider, - Subject: identity.Subject, - Email: identity.Email, - DisplayName: identity.DisplayName, + Provider: Provider, + Subject: identity.Subject, + Email: identity.Email, + DisplayName: identity.DisplayName, + AllowAutoProvision: s.resolveAllowAutoProvision(ctx), }) if err != nil { return nil, bizerr.WrapCode(err, CodeExternalLoginFailed) diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings.go b/linapro-oidc-discord/backend/internal/service/settings/settings.go index afb65391..065e4bd1 100644 --- a/linapro-oidc-discord/backend/internal/service/settings/settings.go +++ b/linapro-oidc-discord/backend/internal/service/settings/settings.go @@ -30,6 +30,10 @@ const ( // ConfigKeyBackendRedirects is the sys_config key holding the JSON object // that maps business state keys to third-party SSO receiver URLs. ConfigKeyBackendRedirects hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.backend_redirects" + // ConfigKeyAllowAutoProvision is the sys_config key holding the + // auto-provision enablement flag ("1" allows the host to create a + // least-privilege platform user for unlinked verified identities). + ConfigKeyAllowAutoProvision hostconfigcap.SysConfigKey = "plugin.linapro-oidc-discord.allow_auto_provision" ) // SecretMask is the fixed indicator returned to the client whenever a client @@ -57,6 +61,9 @@ type Snapshot struct { // BackendRedirects is the raw JSON object mapping business state keys to // third-party SSO receiver URLs. BackendRedirects string + // AllowAutoProvision reports whether the host may auto-provision platform + // users for unlinked verified identities from this provider. + AllowAutoProvision bool } // Projection is the masked settings projection returned to the admin settings @@ -79,6 +86,8 @@ type Projection struct { DefaultBackendRedirect string // BackendRedirects is the raw JSON object of state key to receiver URL. BackendRedirects string + // AllowAutoProvision reports whether auto-provisioning is enabled. + AllowAutoProvision bool } // SaveInput carries the caller-supplied settings values submitted through the @@ -98,6 +107,8 @@ type SaveInput struct { // BackendRedirects replaces the stored state-key-to-receiver JSON object. // A non-empty value must parse as a JSON object of string values. BackendRedirects string + // AllowAutoProvision replaces the stored auto-provision enablement flag. + AllowAutoProvision bool } // Service defines the linapro-oidc-discord settings service surface used by diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings_read.go b/linapro-oidc-discord/backend/internal/service/settings/settings_read.go index 0274733c..6631b52f 100644 --- a/linapro-oidc-discord/backend/internal/service/settings/settings_read.go +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_read.go @@ -24,6 +24,7 @@ func allSettingsKeys() []hostconfigcap.SysConfigKey { ConfigKeyEnableBackendRedirect, ConfigKeyDefaultBackendRedirect, ConfigKeyBackendRedirects, + ConfigKeyAllowAutoProvision, } } @@ -74,6 +75,9 @@ func (s *serviceImpl) Load(ctx context.Context) (*Snapshot, error) { if info := result.Items[ConfigKeyBackendRedirects]; info != nil { snapshot.BackendRedirects = info.Value } + if info := result.Items[ConfigKeyAllowAutoProvision]; info != nil { + snapshot.AllowAutoProvision = info.Value == enabledFlagValue + } } return snapshot, nil } @@ -89,6 +93,7 @@ func projectFromSnapshot(snapshot *Snapshot) *Projection { projection.EnableBackendRedirect = snapshot.EnableBackendRedirect projection.DefaultBackendRedirect = snapshot.DefaultBackendRedirect projection.BackendRedirects = snapshot.BackendRedirects + projection.AllowAutoProvision = snapshot.AllowAutoProvision if snapshot.ClientSecret != "" { projection.ClientSecretConfigured = true projection.ClientSecretMasked = SecretMask diff --git a/linapro-oidc-discord/backend/internal/service/settings/settings_save.go b/linapro-oidc-discord/backend/internal/service/settings/settings_save.go index 9f190c1b..d868491f 100644 --- a/linapro-oidc-discord/backend/internal/service/settings/settings_save.go +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_save.go @@ -59,6 +59,13 @@ func (s *serviceImpl) Save(ctx context.Context, in SaveInput) (*Projection, erro if err := s.setValue(ctx, ConfigKeyBackendRedirects, rules); err != nil { return nil, err } + autoProvisionFlag := "" + if in.AllowAutoProvision { + autoProvisionFlag = enabledFlagValue + } + if err := s.setValue(ctx, ConfigKeyAllowAutoProvision, autoProvisionFlag); err != nil { + return nil, err + } if nextSecret != current.ClientSecret { if err := s.setValue(ctx, ConfigKeyClientSecret, nextSecret); err != nil { return nil, err @@ -78,6 +85,7 @@ func (s *serviceImpl) Save(ctx context.Context, in SaveInput) (*Projection, erro EnableBackendRedirect: in.EnableBackendRedirect, DefaultBackendRedirect: strings.TrimSpace(in.DefaultBackendRedirect), BackendRedirects: rules, + AllowAutoProvision: in.AllowAutoProvision, }), nil } diff --git a/linapro-oidc-discord/frontend/pages/settings.vue b/linapro-oidc-discord/frontend/pages/settings.vue index b2fe333b..638b5326 100644 --- a/linapro-oidc-discord/frontend/pages/settings.vue +++ b/linapro-oidc-discord/frontend/pages/settings.vue @@ -34,6 +34,7 @@ interface SettingsItem { enableBackendRedirect: boolean; defaultBackendRedirect: string; backendRedirects: string; + allowAutoProvision: boolean; } interface RedirectRule { @@ -71,6 +72,7 @@ const formState = reactive({ enableBackendRedirect: false, defaultBackendRedirect: '', rules: [] as RedirectRule[], + allowAutoProvision: false, }); function settingsApi() { @@ -119,6 +121,7 @@ function applySettings(settings: null | SettingsItem) { formState.enableBackendRedirect = settings?.enableBackendRedirect ?? false; formState.defaultBackendRedirect = settings?.defaultBackendRedirect ?? ''; formState.rules = parseRules(settings?.backendRedirects ?? ''); + formState.allowAutoProvision = settings?.allowAutoProvision ?? false; secretConfigured.value = settings?.clientSecretConfigured ?? false; } @@ -176,6 +179,7 @@ async function saveSettings() { enableBackendRedirect: formState.enableBackendRedirect, defaultBackendRedirect: formState.defaultBackendRedirect, backendRedirects: serializeRules(formState.rules), + allowAutoProvision: formState.allowAutoProvision, }, ); applySettings(res?.settings ?? null); @@ -259,6 +263,19 @@ onMounted(loadSettings); allow-clear /> + +
+ + + {{ + $t('plugin.linapro-oidc-discord.settings.autoProvisionLabel') + }} + +
+

+ {{ $t('plugin.linapro-oidc-discord.settings.autoProvisionHint') }} +

+
diff --git a/linapro-oidc-discord/manifest/i18n/en-US/plugin.json b/linapro-oidc-discord/manifest/i18n/en-US/plugin.json index b7ed8f27..713a5d72 100644 --- a/linapro-oidc-discord/manifest/i18n/en-US/plugin.json +++ b/linapro-oidc-discord/manifest/i18n/en-US/plugin.json @@ -37,7 +37,9 @@ "copyLoginUrl": "Copy Login URL", "copied": "Login URL copied.", "copyFailed": "Failed to copy the login URL.", - "deleteRule": "Delete" + "deleteRule": "Delete", + "autoProvisionLabel": "Auto-provision new users", + "autoProvisionHint": "When enabled, a verified Discord identity without a linked local account gets a least-privilege platform account automatically. If the email already belongs to an existing account, login is rejected until the identity is linked from that account." } } } diff --git a/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json b/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json index 42e8620b..5a3e6939 100644 --- a/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json @@ -48,6 +48,9 @@ }, "backendRedirects": { "dc": "业务 state key 到第三方 SSO 接收地址的 JSON 对象;空字符串清除所有规则" + }, + "allowAutoProvision": { + "dc": "允许宿主为未绑定的已验证 Discord 身份自动创建最小权限平台账号" } } }, @@ -80,6 +83,9 @@ }, "backendRedirects": { "dc": "业务 state key 到第三方 SSO 接收地址的 JSON 对象" + }, + "allowAutoProvision": { + "dc": "当前是否允许自动注册未绑定的已验证 Discord 身份" } } } diff --git a/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json b/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json index 6ed50074..29665122 100644 --- a/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json @@ -37,7 +37,9 @@ "copyLoginUrl": "复制登录链接", "copied": "登录链接已复制。", "copyFailed": "复制登录链接失败。", - "deleteRule": "删除" + "deleteRule": "删除", + "autoProvisionLabel": "自动注册新用户", + "autoProvisionHint": "开启后,已验证但未绑定本地账号的 Discord 身份会自动创建最小权限的平台账号。若邮箱已属于现有账号,登录会被拒绝,需先登录该账号完成绑定。" } } } diff --git a/linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql b/linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql deleted file mode 100644 index abf18132..00000000 --- a/linapro-oidc-discord/manifest/sql/001-linapro-oidc-discord-settings.sql +++ /dev/null @@ -1,18 +0,0 @@ --- 001: linapro-oidc-discord plugin settings --- 001:linapro-oidc-discord 插件设置 --- Seeds the platform-level sys_config rows the plugin reads at request time. --- 为插件在请求期读取的平台级 sys_config 行提供初始化数据。 - --- ============================================================ --- Plugin settings seed data: Discord OIDC client credentials, redirect URL, --- and SSO token-delivery configuration --- 插件设置初始化数据:Discord OIDC 客户端凭据、回调地址与 SSO 令牌投递配置 --- ============================================================ -INSERT INTO sys_config ("tenant_id", "name", "key", "value", "is_builtin", "remark", "created_at", "updated_at") VALUES -(0, 'Discord OIDC-Client ID', 'plugin.linapro-oidc-discord.client_id', '', 1, 'Discord OAuth 2.0 client ID issued by the Discord Developer Portal; edit through the Discord OIDC settings page.', NOW(), NOW()), -(0, 'Discord OIDC-Client Secret', 'plugin.linapro-oidc-discord.client_secret', '', 1, 'Discord OAuth 2.0 client secret paired with the client ID; the settings page always returns a masked value.', NOW(), NOW()), -(0, 'Discord OIDC-Redirect URL', 'plugin.linapro-oidc-discord.redirect_url', '', 1, 'Fully-qualified callback URL registered with Discord; must resolve to /portal/linapro-oidc-discord/callback.', NOW(), NOW()), -(0, 'Discord OIDC-Enable SSO Delivery', 'plugin.linapro-oidc-discord.enable_backend_redirect', '', 1, 'SSO token-delivery enablement flag; "1" enables delivery to third-party receiver URLs matched by state key.', NOW(), NOW()), -(0, 'Discord OIDC-Workspace Landing Path', 'plugin.linapro-oidc-discord.default_backend_redirect', '', 1, 'Workspace landing path after a normal external login; empty keeps the host default landing.', NOW(), NOW()), -(0, 'Discord OIDC-SSO Delivery Rules', 'plugin.linapro-oidc-discord.backend_redirects', '', 1, 'JSON object mapping business state keys to third-party SSO receiver URLs.', NOW(), NOW()) -ON CONFLICT DO NOTHING; diff --git a/linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql b/linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql deleted file mode 100644 index 217a74a8..00000000 --- a/linapro-oidc-discord/manifest/sql/uninstall/001-linapro-oidc-discord-settings.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 001: linapro-oidc-discord plugin settings uninstall --- 001:linapro-oidc-discord 插件设置卸载 --- Removes the platform-level sys_config rows seeded during install. --- 删除安装阶段创建的平台级 sys_config 行。 - -DELETE FROM sys_config -WHERE "tenant_id" = 0 - AND "key" IN ( - 'plugin.linapro-oidc-discord.client_id', - 'plugin.linapro-oidc-discord.client_secret', - 'plugin.linapro-oidc-discord.redirect_url', - 'plugin.linapro-oidc-discord.enable_backend_redirect', - 'plugin.linapro-oidc-discord.default_backend_redirect', - 'plugin.linapro-oidc-discord.backend_redirects' - ); diff --git a/linapro-oidc-google/backend/api/settings/v1/settings.go b/linapro-oidc-google/backend/api/settings/v1/settings.go index 50d5eed5..8baff394 100644 --- a/linapro-oidc-google/backend/api/settings/v1/settings.go +++ b/linapro-oidc-google/backend/api/settings/v1/settings.go @@ -25,4 +25,9 @@ type SettingsItem struct { DefaultBackendRedirect string `json:"defaultBackendRedirect" dc:"Workspace landing path used after a normal external login; empty keeps the host default" eg:"/dashboard/analytics"` // BackendRedirects is the JSON object mapping state keys to receiver URLs. BackendRedirects string `json:"backendRedirects" dc:"JSON object mapping business state keys to third-party SSO receiver URLs" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` + // AllowAutoProvision reports whether unlinked verified identities may be + // auto-provisioned as least-privilege platform users. + AllowAutoProvision bool `json:"allowAutoProvision" dc:"True when the host may auto-provision a least-privilege platform user for an unlinked verified Google identity" eg:"false"` + // EnableOneTap reports whether the embeddable One Tap endpoint is enabled. + EnableOneTap bool `json:"enableOneTap" dc:"True when the embeddable Google One Tap endpoint accepts ID Token credentials" eg:"false"` } diff --git a/linapro-oidc-google/backend/api/settings/v1/settings_save.go b/linapro-oidc-google/backend/api/settings/v1/settings_save.go index 06d52321..dc2227cf 100644 --- a/linapro-oidc-google/backend/api/settings/v1/settings_save.go +++ b/linapro-oidc-google/backend/api/settings/v1/settings_save.go @@ -17,6 +17,10 @@ type SaveSettingsReq struct { DefaultBackendRedirect string `json:"defaultBackendRedirect" v:"max-length:512" dc:"Workspace landing path after a normal external login; empty string keeps the host default" eg:"/dashboard/analytics"` // BackendRedirects sets the state-key-to-receiver-URL JSON object. BackendRedirects string `json:"backendRedirects" v:"max-length:4096" dc:"JSON object mapping business state keys to third-party SSO receiver URLs; empty string clears all rules" eg:"{\"crm\":\"https://crm.example.com/sso\"}"` + // AllowAutoProvision toggles host auto-provisioning for unlinked identities. + AllowAutoProvision bool `json:"allowAutoProvision" dc:"Allow the host to auto-provision a least-privilege platform user when a verified Google identity has no linked local account" eg:"false"` + // EnableOneTap toggles the embeddable One Tap login endpoint. + EnableOneTap bool `json:"enableOneTap" dc:"Enable the embeddable Google One Tap endpoint that accepts GSI ID Token credentials" eg:"false"` } // SaveSettingsRes is the response for saving the linapro-oidc-google settings. diff --git a/linapro-oidc-google/backend/internal/controller/login/login_onetap.go b/linapro-oidc-google/backend/internal/controller/login/login_onetap.go new file mode 100644 index 00000000..68db6c41 --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/login/login_onetap.go @@ -0,0 +1,63 @@ +// login_onetap.go implements the browser-facing POST /onetap route that +// consumes the Google One Tap (GSI login_uri) form POST from embeddable +// snippets on third-party pages. The GSI library submits the ID Token as a +// top-level form navigation, so the handler responds with the same redirect +// semantics as the authorize-code callback: SSO delivery to the matched +// receiver URL, or the SPA login handoff. + +package login + +import ( + "crypto/subtle" + "net/http" + "strings" + + "github.com/gogf/gf/v2/net/ghttp" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" + + oauthsvc "lina-plugin-linapro-oidc-google/backend/internal/service/oauth" +) + +// gsiCSRFTokenName is the cookie and form field name Google's GSI library uses +// for its double-submit CSRF token on login_uri form POSTs. +const gsiCSRFTokenName = "g_csrf_token" + +// OneTap handles POST /portal/linapro-oidc-google/onetap. It applies Google's +// double-submit CSRF check when the g_csrf_token cookie is present, validates +// the ID Token credential through the OAuth service, and reuses the SSO +// delivery / SPA handoff redirect logic shared with the authorize-code flow. +func (c *ControllerV1) OneTap(request *ghttp.Request) { + ctx := request.Context() + returnPath := c.resolveReturnPath() + // Google's GSI form POST carries a double-submit CSRF token. Enforce the + // comparison whenever the cookie made it across (same-site embeds); pure + // cross-site embeds may miss the cookie, where the ID Token's signature, + // audience, and expiry remain the integrity anchor. + csrfCookie := strings.TrimSpace(request.Cookie.Get(gsiCSRFTokenName).String()) + csrfBody := strings.TrimSpace(request.Get(gsiCSRFTokenName).String()) + if csrfCookie != "" && subtle.ConstantTimeCompare([]byte(csrfCookie), []byte(csrfBody)) != 1 { + logger.Warningf(ctx, "linapro-oidc-google one tap csrf token mismatch") + request.Response.RedirectTo( + buildErrorRedirect(returnPath, bizerr.NewCode(oauthsvc.CodeOneTapCSRFMismatch).Error()), + http.StatusFound, + ) + return + } + credential := request.Get("credential").String() + stateKey := strings.TrimSpace(request.Get("state").String()) + callback, err := c.oauthSvc.CompleteOneTap(ctx, credential, stateKey) + if err != nil { + logger.Warningf(ctx, "linapro-oidc-google one tap failed: %v", err) + request.Response.RedirectTo(buildErrorRedirect(returnPath, err.Error()), http.StatusFound) + return + } + settings := c.loadSettingsSnapshot(ctx) + // SSO token delivery: identical semantics to the authorize-code callback. + if receiver := resolveSSOReceiver(ctx, settings, stateKey, callback); receiver != "" { + request.Response.RedirectTo(buildReceiverRedirect(receiver, stateKey, callback), http.StatusFound) + return + } + request.Response.RedirectTo(buildSuccessRedirect(returnPath, callback, resolveSPALanding(settings)), http.StatusFound) +} diff --git a/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go index 03bd6daa..cc9e004c 100644 --- a/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go +++ b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go @@ -21,6 +21,8 @@ func (c *ControllerV1) SaveSettings(ctx context.Context, req *v1.SaveSettingsReq EnableBackendRedirect: req.EnableBackendRedirect, DefaultBackendRedirect: req.DefaultBackendRedirect, BackendRedirects: req.BackendRedirects, + AllowAutoProvision: req.AllowAutoProvision, + EnableOneTap: req.EnableOneTap, }) if err != nil { return nil, err @@ -41,5 +43,7 @@ func projectSettingsItem(projection *settingssvc.Projection) *v1.SettingsItem { EnableBackendRedirect: projection.EnableBackendRedirect, DefaultBackendRedirect: projection.DefaultBackendRedirect, BackendRedirects: projection.BackendRedirects, + AllowAutoProvision: projection.AllowAutoProvision, + EnableOneTap: projection.EnableOneTap, } } diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth.go b/linapro-oidc-google/backend/internal/service/oauth/oauth.go index 589d74c0..9b89c0b3 100644 --- a/linapro-oidc-google/backend/internal/service/oauth/oauth.go +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth.go @@ -40,6 +40,13 @@ type Service interface { // after the host external-login exchange completes. It reflects the // configured LoginReturnPath value so wiring stays in one place. LoginReturnPath() string + // CompleteOneTap validates one Google One Tap ID Token credential and + // exchanges the verified identity for a host login outcome. The stateKey + // comes from the embed's login_uri query and selects the SSO delivery + // rule; the ID Token's own signature, audience, and expiry provide the + // integrity guarantees that the authorize-code flow gets from the signed + // state token. + CompleteOneTap(ctx context.Context, credential string, stateKey string) (*CallbackOutput, error) } // AuthorizeRequest describes the authorize redirect the browser should follow. @@ -94,6 +101,7 @@ type serviceImpl struct { configResolver *ConfigResolver // configResolver layers persisted settings over static defaults per request. verifier IdentityVerifier // verifier exchanges an OAuth code for a verified identity. stateCodec StateCodec // stateCodec signs and validates self-contained state tokens. + idTokenVerifier IDTokenVerifier // idTokenVerifier validates One Tap ID Token credentials; nil disables One Tap. } // New creates and returns a new Google OIDC login service instance. Each @@ -101,17 +109,20 @@ type serviceImpl struct { // production HTTP verifier at real deployment time while unit tests inject a // deterministic verifier. The shared configResolver is also consumed by the // HTTP identity verifier so both read identical request-time settings. +// idTokenVerifier may be nil, which disables the One Tap endpoint fail-closed. func New( externalLoginSvc externallogin.Service, configResolver *ConfigResolver, verifier IdentityVerifier, stateCodec StateCodec, + idTokenVerifier IDTokenVerifier, ) Service { return &serviceImpl{ externalLoginSvc: externalLoginSvc, configResolver: configResolver, verifier: verifier, stateCodec: stateCodec, + idTokenVerifier: idTokenVerifier, } } diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go index 2bdeb47b..5d17b67e 100644 --- a/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go @@ -18,6 +18,20 @@ import ( "lina-core/pkg/plugin/capability/authcap/externallogin" ) +// resolveAllowAutoProvision reads the admin-controlled auto-provision flag at +// request time so settings edits apply without a restart. A missing resolver +// or settings load failure keeps auto-provisioning disabled fail-closed. +func (s *serviceImpl) resolveAllowAutoProvision(ctx context.Context) bool { + if s == nil || s.configResolver == nil || s.configResolver.settingsSvc == nil { + return false + } + snapshot, err := s.configResolver.settingsSvc.Load(ctx) + if err != nil || snapshot == nil { + return false + } + return snapshot.AllowAutoProvision +} + // CompleteCallback validates the signed callback state, exchanges the code // for a verified identity, and hands the identity to the host external-login // seam. Provisioning, tenant resolution, and token minting stay host-owned; @@ -64,10 +78,11 @@ func (s *serviceImpl) CompleteCallback(ctx context.Context, in CallbackInput) (* identity.Email, ) loginOut, err := s.externalLoginSvc.LoginByVerifiedIdentity(ctx, externallogin.LoginInput{ - Provider: Provider, - Subject: identity.Subject, - Email: identity.Email, - DisplayName: identity.DisplayName, + Provider: Provider, + Subject: identity.Subject, + Email: identity.Email, + DisplayName: identity.DisplayName, + AllowAutoProvision: s.resolveAllowAutoProvision(ctx), }) if err != nil { return nil, bizerr.WrapCode(err, CodeExternalLoginFailed) diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go index 9b671af7..cae8f8ef 100644 --- a/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go @@ -58,4 +58,17 @@ var ( "Google external login failed", gcode.CodeInternalError, ) + // CodeOneTapCSRFMismatch reports that the GSI double-submit CSRF check failed. + CodeOneTapCSRFMismatch = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_ONE_TAP_CSRF_MISMATCH", + "One Tap CSRF validation failed", + gcode.CodeSecurityReason, + ) + // CodeOneTapDisabled reports that the One Tap endpoint was called while the + // admin switch is off. + CodeOneTapDisabled = bizerr.MustDefine( + "PLUGIN_OIDC_GOOGLE_ONE_TAP_DISABLED", + "Google One Tap login is disabled", + gcode.CodeInvalidOperation, + ) ) diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_idtoken_verifier.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_idtoken_verifier.go new file mode 100644 index 00000000..d6876d04 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_idtoken_verifier.go @@ -0,0 +1,98 @@ +// oauth_idtoken_verifier.go verifies Google-issued ID Tokens delivered by the +// Google One Tap embed (GSI login_uri form POST). Verification is fully local +// once the JWKS keys are cached: RSA signature, issuer, audience (the +// configured client ID), expiry, and the verified-email claim are all +// enforced before the identity reaches the host external-login seam. + +package oauth + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/golang-jwt/jwt/v5" + + "lina-core/pkg/bizerr" +) + +// googleIssuer and googleIssuerHTTPS are the accepted `iss` claim values for +// Google ID Tokens per Google's OpenID Connect documentation. +const ( + googleIssuer = "accounts.google.com" + googleIssuerHTTPS = "https://accounts.google.com" +) + +// IDTokenVerifier validates one Google ID Token and projects the verified +// identity. It is the One Tap counterpart of IdentityVerifier: both produce +// the same neutral VerifiedIdentity consumed by the login orchestration. +type IDTokenVerifier interface { + // VerifyIDToken validates the raw JWT credential and returns the identity. + VerifyIDToken(ctx context.Context, rawToken string) (*VerifiedIdentity, error) +} + +// googleIDTokenClaims models the Google ID Token claims the plugin consumes. +type googleIDTokenClaims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + jwt.RegisteredClaims +} + +// jwksIDTokenVerifier is the production ID Token verifier backed by Google's +// JWKS keys and the shared request-time settings resolver. +type jwksIDTokenVerifier struct { + jwks *jwksClient + settings SettingsSource +} + +// NewJWKSIDTokenVerifier returns the production One Tap ID Token verifier. +// The settings source supplies the expected audience (client ID) at request +// time so credential rotations apply without a restart. +func NewJWKSIDTokenVerifier(settings SettingsSource) IDTokenVerifier { + return &jwksIDTokenVerifier{jwks: newJWKSClient(), settings: settings} +} + +// VerifyIDToken validates signature, issuer, audience, expiry, and the +// verified-email claim, then projects the neutral identity. +func (v *jwksIDTokenVerifier) VerifyIDToken(ctx context.Context, rawToken string) (*VerifiedIdentity, error) { + trimmed := strings.TrimSpace(rawToken) + if trimmed == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: one tap credential is empty"), CodeIdentityVerifyFailed) + } + if v == nil || v.settings == nil || v.jwks == nil { + return nil, bizerr.WrapCode(gerror.New("oidc google: one tap verifier is not wired"), CodeConfigMissing) + } + clientID := strings.TrimSpace(v.settings.ResolveConfig(ctx).ClientID) + if clientID == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: client id is not configured"), CodeConfigMissing) + } + claims := &googleIDTokenClaims{} + token, err := jwt.ParseWithClaims(trimmed, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, gerror.Newf("unexpected signing method %v", token.Header["alg"]) + } + kid, _ := token.Header["kid"].(string) + return v.jwks.keyForKid(ctx, kid) + }, jwt.WithExpirationRequired(), jwt.WithAudience(clientID)) + if err != nil || !token.Valid { + return nil, bizerr.WrapCode(gerror.Wrap(err, "oidc google: one tap credential validation failed"), CodeIdentityVerifyFailed) + } + issuer := strings.TrimSpace(claims.Issuer) + if issuer != googleIssuer && issuer != googleIssuerHTTPS { + return nil, bizerr.WrapCode(gerror.Newf("oidc google: unexpected issuer %q", issuer), CodeIdentityVerifyFailed) + } + subject := strings.TrimSpace(claims.Subject) + email := strings.TrimSpace(claims.Email) + if subject == "" || email == "" { + return nil, bizerr.WrapCode(gerror.New("oidc google: one tap claims missing sub or email"), CodeIdentityVerifyFailed) + } + if !claims.EmailVerified { + return nil, bizerr.WrapCode(gerror.New("oidc google: account email is not verified by Google"), CodeEmailNotVerified) + } + return &VerifiedIdentity{ + Subject: subject, + Email: email, + DisplayName: claims.Name, + }, nil +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_jwks.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_jwks.go new file mode 100644 index 00000000..e691f448 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_jwks.go @@ -0,0 +1,126 @@ +// oauth_jwks.go implements a cached Google JWKS (JSON Web Key Set) client +// used to verify Google-issued ID Tokens locally. Keys are fetched from +// Google's certs endpoint through the bounded HTTP client and cached for a +// fixed TTL so One Tap logins do not hit Google on every verification. + +package oauth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" +) + +// googleJWKSEndpoint serves Google's current ID Token signing keys. +const googleJWKSEndpoint = "https://www.googleapis.com/oauth2/v3/certs" + +// jwksCacheTTL bounds how long fetched keys are reused before a refresh. +// Google rotates keys on the order of days; one hour keeps rotation lag +// negligible while avoiding per-login fetches. +const jwksCacheTTL = time.Hour + +// jwksClient fetches and caches Google's RSA public keys indexed by key ID. +type jwksClient struct { + httpClient *http.Client + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +// newJWKSClient creates a JWKS client with a bounded outbound HTTP client. +func newJWKSClient() *jwksClient { + return &jwksClient{ + httpClient: &http.Client{Timeout: httpTimeout}, + keys: map[string]*rsa.PublicKey{}, + } +} + +// keyForKid returns the RSA public key for one JWT key ID, refreshing the +// cached key set when the kid is unknown or the cache expired. +func (c *jwksClient) keyForKid(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c.mu.Lock() + defer c.mu.Unlock() + if key, ok := c.keys[kid]; ok && time.Since(c.fetchedAt) < jwksCacheTTL { + return key, nil + } + if err := c.refreshLocked(ctx); err != nil { + return nil, err + } + if key, ok := c.keys[kid]; ok { + return key, nil + } + return nil, bizerr.WrapCode( + gerror.Newf("oidc google: no JWKS key for kid %q", kid), + CodeIdentityVerifyFailed, + ) +} + +// refreshLocked fetches the current key set. Callers must hold c.mu. +func (c *jwksClient) refreshLocked(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleJWKSEndpoint, nil) + if err != nil { + return bizerr.WrapCode(gerror.Wrap(err, "oidc google: build JWKS request failed"), CodeIdentityVerifyFailed) + } + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return bizerr.WrapCode(gerror.Wrap(err, "oidc google: fetch JWKS failed"), CodeIdentityVerifyFailed) + } + defer closeResponseBody(ctx, resp, "google jwks endpoint") + body, err := io.ReadAll(resp.Body) + if err != nil { + return bizerr.WrapCode(gerror.Wrap(err, "oidc google: read JWKS response failed"), CodeIdentityVerifyFailed) + } + if resp.StatusCode != http.StatusOK { + return bizerr.WrapCode( + gerror.Newf("oidc google: JWKS endpoint returned status %d: %s", resp.StatusCode, truncate(string(body), errorBodyLimit)), + CodeIdentityVerifyFailed, + ) + } + var payload struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err = json.Unmarshal(body, &payload); err != nil { + return bizerr.WrapCode(gerror.Wrap(err, "oidc google: decode JWKS response failed"), CodeIdentityVerifyFailed) + } + keys := make(map[string]*rsa.PublicKey, len(payload.Keys)) + for _, jwk := range payload.Keys { + if !strings.EqualFold(jwk.Kty, "RSA") || jwk.Kid == "" { + continue + } + nBytes, decodeErr := base64.RawURLEncoding.DecodeString(jwk.N) + if decodeErr != nil { + continue + } + eBytes, decodeErr := base64.RawURLEncoding.DecodeString(jwk.E) + if decodeErr != nil { + continue + } + keys[jwk.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + } + if len(keys) == 0 { + return bizerr.WrapCode(gerror.New("oidc google: JWKS response contained no usable RSA keys"), CodeIdentityVerifyFailed) + } + c.keys = keys + c.fetchedAt = time.Now() + return nil +} diff --git a/linapro-oidc-google/backend/internal/service/oauth/oauth_onetap.go b/linapro-oidc-google/backend/internal/service/oauth/oauth_onetap.go new file mode 100644 index 00000000..59bd17ac --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_onetap.go @@ -0,0 +1,91 @@ +// oauth_onetap.go implements the Google One Tap login step. The embeddable +// GSI snippet form-POSTs an ID Token credential to the plugin's public +// /onetap route; this service validates the token locally (JWKS signature, +// issuer, audience, expiry, verified email) and exchanges the verified +// identity through the same host external-login seam as the authorize-code +// flow, so auto-provisioning and SSO delivery behave identically across both +// entry points. + +package oauth + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/logger" + "lina-core/pkg/plugin/capability/authcap/externallogin" +) + +// CompleteOneTap validates the One Tap credential and returns the host login +// outcome plus the echoed SSO state key. See the Service interface contract. +func (s *serviceImpl) CompleteOneTap(ctx context.Context, credential string, stateKey string) (*CallbackOutput, error) { + if s == nil || s.idTokenVerifier == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc google: one tap verifier is not wired"), + CodeExternalLoginUnavailable, + ) + } + if !s.resolveEnableOneTap(ctx) { + return nil, bizerr.WrapCode( + gerror.New("oidc google: one tap login is disabled"), + CodeOneTapDisabled, + ) + } + if s.externalLoginSvc == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc google: external-login service is unavailable"), + CodeExternalLoginUnavailable, + ) + } + identity, err := s.idTokenVerifier.VerifyIDToken(ctx, credential) + if err != nil { + return nil, err + } + logger.Infof( + ctx, + "linapro-oidc-google one tap verified provider=%s subject=%s email=%s", + Provider, + identity.Subject, + identity.Email, + ) + loginOut, err := s.externalLoginSvc.LoginByVerifiedIdentity(ctx, externallogin.LoginInput{ + Provider: Provider, + Subject: identity.Subject, + Email: identity.Email, + DisplayName: identity.DisplayName, + AllowAutoProvision: s.resolveAllowAutoProvision(ctx), + }) + if err != nil { + return nil, bizerr.WrapCode(err, CodeExternalLoginFailed) + } + if loginOut == nil { + return nil, bizerr.WrapCode( + gerror.New("oidc google: external-login returned nil outcome"), + CodeExternalLoginFailed, + ) + } + return &CallbackOutput{ + AccessToken: loginOut.AccessToken, + RefreshToken: loginOut.RefreshToken, + PreToken: loginOut.PreToken, + TenantCandidates: loginOut.TenantCandidates, + StateKey: strings.TrimSpace(stateKey), + }, nil +} + +// resolveEnableOneTap reads the admin-controlled One Tap flag at request time +// so settings edits apply without a restart. Missing wiring or a settings +// load failure keeps One Tap disabled fail-closed. +func (s *serviceImpl) resolveEnableOneTap(ctx context.Context) bool { + if s == nil || s.configResolver == nil || s.configResolver.settingsSvc == nil { + return false + } + snapshot, err := s.configResolver.settingsSvc.Load(ctx) + if err != nil || snapshot == nil { + return false + } + return snapshot.EnableOneTap +} diff --git a/linapro-oidc-google/backend/internal/service/settings/settings.go b/linapro-oidc-google/backend/internal/service/settings/settings.go index 23e6b3d3..aef671e8 100644 --- a/linapro-oidc-google/backend/internal/service/settings/settings.go +++ b/linapro-oidc-google/backend/internal/service/settings/settings.go @@ -30,6 +30,13 @@ const ( // ConfigKeyBackendRedirects is the sys_config key holding the JSON object // that maps business state keys to third-party SSO receiver URLs. ConfigKeyBackendRedirects hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.backend_redirects" + // ConfigKeyAllowAutoProvision is the sys_config key holding the + // auto-provision enablement flag ("1" allows the host to create a + // least-privilege platform user for unlinked verified identities). + ConfigKeyAllowAutoProvision hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.allow_auto_provision" + // ConfigKeyEnableOneTap is the sys_config key holding the Google One Tap + // embed enablement flag ("1" accepts ID Token credentials on /onetap). + ConfigKeyEnableOneTap hostconfigcap.SysConfigKey = "plugin.linapro-oidc-google.enable_one_tap" ) // SecretMask is the fixed indicator returned to the client whenever a client @@ -57,6 +64,12 @@ type Snapshot struct { // BackendRedirects is the raw JSON object mapping business state keys to // third-party SSO receiver URLs. BackendRedirects string + // AllowAutoProvision reports whether the host may auto-provision platform + // users for unlinked verified identities from this provider. + AllowAutoProvision bool + // EnableOneTap reports whether the One Tap embed endpoint accepts + // ID Token credentials. + EnableOneTap bool } // Projection is the masked settings projection returned to the admin settings @@ -79,6 +92,10 @@ type Projection struct { DefaultBackendRedirect string // BackendRedirects is the raw JSON object of state key to receiver URL. BackendRedirects string + // AllowAutoProvision reports whether auto-provisioning is enabled. + AllowAutoProvision bool + // EnableOneTap reports whether the One Tap embed endpoint is enabled. + EnableOneTap bool } // SaveInput carries the caller-supplied settings values submitted through the @@ -98,6 +115,10 @@ type SaveInput struct { // BackendRedirects replaces the stored state-key-to-receiver JSON object. // A non-empty value must parse as a JSON object of string values. BackendRedirects string + // AllowAutoProvision replaces the stored auto-provision enablement flag. + AllowAutoProvision bool + // EnableOneTap replaces the stored One Tap enablement flag. + EnableOneTap bool } // Service defines the linapro-oidc-google settings service surface used by diff --git a/linapro-oidc-google/backend/internal/service/settings/settings_read.go b/linapro-oidc-google/backend/internal/service/settings/settings_read.go index ca7cc786..196b830c 100644 --- a/linapro-oidc-google/backend/internal/service/settings/settings_read.go +++ b/linapro-oidc-google/backend/internal/service/settings/settings_read.go @@ -24,6 +24,8 @@ func allSettingsKeys() []hostconfigcap.SysConfigKey { ConfigKeyEnableBackendRedirect, ConfigKeyDefaultBackendRedirect, ConfigKeyBackendRedirects, + ConfigKeyAllowAutoProvision, + ConfigKeyEnableOneTap, } } @@ -74,6 +76,12 @@ func (s *serviceImpl) Load(ctx context.Context) (*Snapshot, error) { if info := result.Items[ConfigKeyBackendRedirects]; info != nil { snapshot.BackendRedirects = info.Value } + if info := result.Items[ConfigKeyAllowAutoProvision]; info != nil { + snapshot.AllowAutoProvision = info.Value == enabledFlagValue + } + if info := result.Items[ConfigKeyEnableOneTap]; info != nil { + snapshot.EnableOneTap = info.Value == enabledFlagValue + } } return snapshot, nil } @@ -89,6 +97,8 @@ func projectFromSnapshot(snapshot *Snapshot) *Projection { projection.EnableBackendRedirect = snapshot.EnableBackendRedirect projection.DefaultBackendRedirect = snapshot.DefaultBackendRedirect projection.BackendRedirects = snapshot.BackendRedirects + projection.AllowAutoProvision = snapshot.AllowAutoProvision + projection.EnableOneTap = snapshot.EnableOneTap if snapshot.ClientSecret != "" { projection.ClientSecretConfigured = true projection.ClientSecretMasked = SecretMask diff --git a/linapro-oidc-google/backend/internal/service/settings/settings_save.go b/linapro-oidc-google/backend/internal/service/settings/settings_save.go index b6ada0db..76a8892c 100644 --- a/linapro-oidc-google/backend/internal/service/settings/settings_save.go +++ b/linapro-oidc-google/backend/internal/service/settings/settings_save.go @@ -59,6 +59,20 @@ func (s *serviceImpl) Save(ctx context.Context, in SaveInput) (*Projection, erro if err := s.setValue(ctx, ConfigKeyBackendRedirects, rules); err != nil { return nil, err } + autoProvisionFlag := "" + if in.AllowAutoProvision { + autoProvisionFlag = enabledFlagValue + } + if err := s.setValue(ctx, ConfigKeyAllowAutoProvision, autoProvisionFlag); err != nil { + return nil, err + } + oneTapFlag := "" + if in.EnableOneTap { + oneTapFlag = enabledFlagValue + } + if err := s.setValue(ctx, ConfigKeyEnableOneTap, oneTapFlag); err != nil { + return nil, err + } if nextSecret != current.ClientSecret { if err := s.setValue(ctx, ConfigKeyClientSecret, nextSecret); err != nil { return nil, err @@ -78,6 +92,8 @@ func (s *serviceImpl) Save(ctx context.Context, in SaveInput) (*Projection, erro EnableBackendRedirect: in.EnableBackendRedirect, DefaultBackendRedirect: strings.TrimSpace(in.DefaultBackendRedirect), BackendRedirects: rules, + AllowAutoProvision: in.AllowAutoProvision, + EnableOneTap: in.EnableOneTap, }), nil } diff --git a/linapro-oidc-google/backend/plugin.go b/linapro-oidc-google/backend/plugin.go index 3c5bad03..5892365f 100644 --- a/linapro-oidc-google/backend/plugin.go +++ b/linapro-oidc-google/backend/plugin.go @@ -82,6 +82,7 @@ func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) err configResolver, oauthsvc.NewHTTPIdentityVerifier(configResolver), oauthsvc.NewHMACStateCodec(), + oauthsvc.NewJWKSIDTokenVerifier(configResolver), ) loginController := loginctrl.NewV1(loginSvc, pluginSettingsSvc) settingsController := settingsctrl.NewV1(pluginSettingsSvc) @@ -94,6 +95,7 @@ func registerRoutes(ctx context.Context, registrar pluginhost.HTTPRegistrar) err ) group.GET("/login", loginController.Start) group.GET("/callback", loginController.Callback) + group.POST("/onetap", loginController.OneTap) }) routes.Group(routes.APIPrefix(), func(group pluginhost.RouteGroup) { group.Group("/api/v1", func(group pluginhost.RouteGroup) { diff --git a/linapro-oidc-google/frontend/pages/settings.vue b/linapro-oidc-google/frontend/pages/settings.vue index 43b73007..cda0f084 100644 --- a/linapro-oidc-google/frontend/pages/settings.vue +++ b/linapro-oidc-google/frontend/pages/settings.vue @@ -34,6 +34,8 @@ interface SettingsItem { enableBackendRedirect: boolean; defaultBackendRedirect: string; backendRedirects: string; + allowAutoProvision: boolean; + enableOneTap: boolean; } interface RedirectRule { @@ -71,8 +73,43 @@ const formState = reactive({ enableBackendRedirect: false, defaultBackendRedirect: '', rules: [] as RedirectRule[], + allowAutoProvision: false, + enableOneTap: false, }); +/** oneTapStateKey lets the admin pick which SSO rule the embed targets. */ +const oneTapStateKey = ref(''); + +/** + * oneTapEmbedCode renders the copy-paste GSI snippet for third-party pages. + * The login_uri points at the plugin's public One Tap endpoint; an optional + * state key routes the outcome to the matching SSO delivery rule. + */ +const oneTapEmbedCode = computed(() => { + const stateQuery = oneTapStateKey.value.trim() + ? `?state=${encodeURIComponent(oneTapStateKey.value.trim())}` + : ''; + const loginUri = `${window.location.origin}/portal/${pluginID}/onetap${stateQuery}`; + return [ + '