diff --git a/README.md b/README.md index bb822dd4..158a8178 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ The workspace currently contains source plugins compiled into the host, plus one | `linapro-monitor-operlog` | `source` | `tenant_aware` | `tenant_scoped` | Operation log persistence and governance pages | | `linapro-monitor-loginlog` | `source` | `tenant_aware` | `tenant_scoped` | Login log persistence and governance pages | | `linapro-ops-demo-guard` | `source` | `tenant_aware` | `global` | Demo-environment read-only protection and global write-operation interception | +| `linapro-oidc-core` | `source` | `platform_only` | `global` | External identity linkage storage, resolve/provision engine behind the host external-login seam, and current-user identity bind/unbind/list | +| `linapro-oidc-google` | `source` | `platform_only` | `global` | Google OIDC third-party login reference on the host external-identity seam; depends on `linapro-oidc-core` | +| `linapro-oidc-discord` | `source` | `platform_only` | `global` | Discord OIDC third-party login reference on the host external-identity seam; depends on `linapro-oidc-core` | | `linapro-demo-source` | `source` | `tenant_aware` | `tenant_scoped` | Source plugin example for menu pages, public routes, and protected routes | | `linapro-demo-dynamic` | `dynamic` | `tenant_aware` | `tenant_scoped` | Dynamic `WASM` plugin example for embedded menu pages, plugin-owned `SQL` table `CRUD`, and standalone static pages | diff --git a/README.zh-CN.md b/README.zh-CN.md index 30c156d2..e240baec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,6 +18,9 @@ LinaPro 将`apps/lina-core`定位为稳定的全栈框架宿主。宿主保留 | `linapro-monitor-operlog` | `source` | `tenant_aware` | `tenant_scoped` | 操作日志持久化与治理页面 | | `linapro-monitor-loginlog` | `source` | `tenant_aware` | `tenant_scoped` | 登录日志持久化与治理页面 | | `linapro-ops-demo-guard` | `source` | `tenant_aware` | `global` | 演示环境只读保护与全局写操作拦截 | +| `linapro-oidc-core` | `source` | `platform_only` | `global` | 外部身份链接存储、宿主外部登录 seam 背后的解析/开户引擎,以及当前用户身份绑定/解绑/列举 | +| `linapro-oidc-google` | `source` | `platform_only` | `global` | 基于宿主外部身份 seam 的 Google OIDC 第三方登录参考实现;依赖 `linapro-oidc-core` | +| `linapro-oidc-discord` | `source` | `platform_only` | `global` | 基于宿主外部身份 seam 的 Discord OIDC 第三方登录参考实现;依赖 `linapro-oidc-core` | | `linapro-demo-source` | `source` | `tenant_aware` | `tenant_scoped` | 源码插件菜单页面、公开路由和受保护路由示例 | | `linapro-demo-dynamic` | `dynamic` | `tenant_aware` | `tenant_scoped` | 动态`WASM`插件示例,演示菜单内嵌页面、插件自有`SQL`表`CRUD`和独立静态页面 | diff --git a/linapro-oidc-core/Makefile b/linapro-oidc-core/Makefile new file mode 100644 index 00000000..e27b6e93 --- /dev/null +++ b/linapro-oidc-core/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-core/README.md b/linapro-oidc-core/README.md new file mode 100644 index 00000000..a1f2fe72 --- /dev/null +++ b/linapro-oidc-core/README.md @@ -0,0 +1,62 @@ +# linapro-oidc-core + +`linapro-oidc-core` is the official LinaPro source plugin that owns external identity storage and the provider engine behind the host external-login seam. + +English | [简体中文](README.zh-CN.md) + +## Scope + +This plugin owns: + +- the `user_external_identity` linkage table mapping a verified `(provider, subject)` pair to a local user account +- the `externalidentityspi.Provider` engine: resolution, auto-provisioning policy (same-email conflict rejection, email-less deterministic username anchors, idempotent `(provider, subject)` de-duplication), and least-privilege account creation delegated back to the host user owner through `usercap.ProvisionExternal` +- the current-user identity binding API: list, bind, and unbind under `/x/linapro-oidc-core/api/v1/plugins/linapro-oidc-core/identities` + +OAuth protocol plugins such as `linapro-oidc-google` and `linapro-oidc-discord` depend on this plugin. They keep verifying identities and calling the host `externallogin` seam unchanged; this plugin never calls `LoginByVerifiedIdentity` and declares no provider-ID ownership. + +## Host Boundary + +The host keeps token minting, session persistence, tenant resolution, pre-login-token handoff, login-IP policy, and auth hooks. This plugin registers only an engine factory through `plugin.Providers().ProvideExternalIdentityProvider(...)`; the host manager lazily constructs the provider gated by plugin enablement and binds the manager-backed seam into the auth service. + +When this plugin is not installed or is disabled, external login is fail-closed: no linkage resolves, no account is provisioned, and no session is issued. Re-enabling the plugin restores external login immediately. Disabling keeps the linkage table data; uninstalling with storage purge drops the table but never cascade-deletes provisioned user accounts, which stay governable through host user management. + +## Data Permission Boundary + +External identity linkages are self-isolated user resources: + +- login resolution uses only the authoritative `(provider, subject)` partial unique index and reports a uniform not-provisioned outcome, never leaking whether an email exists on another account +- bind, unbind, and list act exclusively on the current session user's own linkages; cross-user targets are rejected as a whole +- a `(provider, subject)` pair already owned by another account is rejected with a conflict error; re-binding an identity the current user already owns succeeds idempotently + +## Provisioning Contract + +Host account provisioning and the plugin linkage write cannot share one database transaction across module boundaries. Correctness converges on the `(provider, subject)` partial unique index: the account is provisioned first, then the linkage is inserted; a unique-index conflict from a concurrent provision is absorbed by reusing the winning linkage instead of surfacing a 500. Email-less identities derive a deterministic, collision-resistant username anchor from the identity digest, so retries reuse the same host account. + +## Directory Layout + +```text +linapro-oidc-core/ + plugin.yaml + plugin_embed.go + go.mod + Makefile + backend/ + plugin.go + api/identity/v1/ list/bind/unbind DTOs + internal/ + controller/identity/ current-user identity binding handlers + service/identity/ provider engine and linkage policy + dao/, model/do/, model/entity/ generated table access objects + manifest/ + sql/ install SQL for user_external_identity + sql/uninstall/ uninstall SQL (drop table on purge) + i18n/en-US/, i18n/zh-CN/ error and API documentation resources + hack/config.yaml plugin-local DAO generation config +``` + +## Review Checklist + +- linkage storage, provisioning policy, and binding stay inside this plugin +- token, session, and tenant minting stay host-owned +- the engine factory is declared through `ProvideExternalIdentityProvider`; no provider-ID ownership is declared +- disable keeps linkage data and fails external login closed; uninstall purge drops only the plugin table diff --git a/linapro-oidc-core/README.zh-CN.md b/linapro-oidc-core/README.zh-CN.md new file mode 100644 index 00000000..aa1f394d --- /dev/null +++ b/linapro-oidc-core/README.zh-CN.md @@ -0,0 +1,62 @@ +# linapro-oidc-core + +`linapro-oidc-core`是 LinaPro 官方源码插件,拥有外部身份存储和宿主外部登录 seam 背后的 provider 引擎。 + +[English](README.md) | 简体中文 + +## 能力边界 + +本插件拥有: + +- `user_external_identity`链接表,把已验证的`(provider, subject)`键映射到本地用户账号 +- `externalidentityspi.Provider`引擎:身份解析、自动开户策略(同邮箱冲突拒绝、无邮箱确定性用户名 anchor、幂等`(provider, subject)`去重),以及经`usercap.ProvisionExternal`委托回宿主用户域的最小权限建号 +- 当前用户身份绑定 API:`/x/linapro-oidc-core/api/v1/plugins/linapro-oidc-core/identities`下的列举、绑定与解绑 + +`linapro-oidc-google`和`linapro-oidc-discord`等 OAuth 协议插件依赖本插件。它们继续负责身份验签并调用宿主`externallogin` seam,路径不变;本插件从不调用`LoginByVerifiedIdentity`,也不声明任何 provider ID 归属。 + +## 宿主边界 + +宿主保留 token 铸造、会话持久化、租户解析、预登录令牌交接、登录 IP 策略与认证 hook。本插件只通过`plugin.Providers().ProvideExternalIdentityProvider(...)`注册引擎工厂;宿主 manager 按插件启用状态惰性构造 provider,并把 manager-backed seam 绑定进 auth 服务。 + +本插件未安装或被禁用时,外部登录 fail-closed:不解析链接、不开户、不签发会话。重新启用后外部登录立即恢复。禁用保留链接表数据;卸载并确认清除数据时删除该表,但绝不级联删除已开户的用户账号,这些账号仍可通过宿主用户管理治理。 + +## 数据权限边界 + +外部身份链接是用户自隔离资源: + +- 登录解析仅使用权威的`(provider, subject)`部分唯一索引,并返回统一的未开户结果,绝不泄露某邮箱是否存在于其他账号 +- 绑定、解绑与列举仅作用于当前会话用户自己的链接;越权目标整体拒绝 +- `(provider, subject)`已被其他账号占用时返回冲突错误;重复绑定当前用户已拥有的身份幂等成功 + +## 开户契约 + +宿主建号与插件链接写入跨模块边界,无法共享单个数据库事务。正确性收敛在`(provider, subject)`部分唯一索引上:先建号、再写链接;并发开户触发唯一索引冲突时复用胜出链接,而不是冒泡 500。无邮箱身份从身份摘要派生确定性、碰撞抗性的用户名 anchor,重试会复用同一宿主账号。 + +## 目录结构 + +```text +linapro-oidc-core/ + plugin.yaml + plugin_embed.go + go.mod + Makefile + backend/ + plugin.go + api/identity/v1/ list/bind/unbind DTO + internal/ + controller/identity/ current-user identity binding handlers + service/identity/ provider engine and linkage policy + dao/, model/do/, model/entity/ generated table access objects + manifest/ + sql/ install SQL for user_external_identity + sql/uninstall/ uninstall SQL (drop table on purge) + i18n/en-US/, i18n/zh-CN/ error and API documentation resources + hack/config.yaml plugin-local DAO generation config +``` + +## 审查清单 + +- 链接存储、开户策略与绑定逻辑保持在本插件内 +- token、会话与租户铸造保持宿主独占 +- 引擎工厂通过`ProvideExternalIdentityProvider`声明;不声明 provider ID 归属 +- 禁用保留链接数据并使外部登录 fail-closed;卸载清除仅删除插件自有表 diff --git a/linapro-oidc-core/backend/api/identity/identity.go b/linapro-oidc-core/backend/api/identity/identity.go new file mode 100644 index 00000000..817ffc83 --- /dev/null +++ b/linapro-oidc-core/backend/api/identity/identity.go @@ -0,0 +1,17 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package identity + +import ( + "context" + + "lina-plugin-linapro-oidc-core/backend/api/identity/v1" +) + +type IIdentityV1 interface { + BindIdentity(ctx context.Context, req *v1.BindIdentityReq) (res *v1.BindIdentityRes, err error) + ListIdentities(ctx context.Context, req *v1.ListIdentitiesReq) (res *v1.ListIdentitiesRes, err error) + UnbindIdentity(ctx context.Context, req *v1.UnbindIdentityReq) (res *v1.UnbindIdentityRes, err error) +} diff --git a/linapro-oidc-core/backend/api/identity/v1/identity_bind.go b/linapro-oidc-core/backend/api/identity/v1/identity_bind.go new file mode 100644 index 00000000..6d7a6ef5 --- /dev/null +++ b/linapro-oidc-core/backend/api/identity/v1/identity_bind.go @@ -0,0 +1,18 @@ +// identity_bind.go defines the request and response DTOs for binding one +// verified external identity to the current session user. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// BindIdentityReq is the request for binding one verified external identity to +// the current session user. +type BindIdentityReq struct { + g.Meta `path:"/plugins/linapro-oidc-core/identities" method:"post" tags:"External Identity" summary:"Bind an external identity" dc:"Link one verified external identity (provider + subject) to the current session user. The caller must have already verified the identity through the owning OAuth plugin; this endpoint performs no OAuth exchange. Binding only acts on the current session user, and a (provider, subject) pair already owned by another account is rejected with a conflict error. Re-binding an identity the current user already owns succeeds idempotently."` + Provider string `json:"provider" v:"required|length:1,64" dc:"Stable external provider ID owned by the verifying OAuth plugin" eg:"google"` + Subject string `json:"subject" v:"required|length:1,191" dc:"Immutable provider-issued subject identifier obtained from the verified identity" eg:"110169484474386276334"` + Email string `json:"email" v:"length:0,191" dc:"Optional verified email captured as an audit snapshot only; never used as a resolution key" eg:"user@example.com"` +} + +// BindIdentityRes is the response for binding one external identity. +type BindIdentityRes struct{} diff --git a/linapro-oidc-core/backend/api/identity/v1/identity_list.go b/linapro-oidc-core/backend/api/identity/v1/identity_list.go new file mode 100644 index 00000000..65a4e457 --- /dev/null +++ b/linapro-oidc-core/backend/api/identity/v1/identity_list.go @@ -0,0 +1,26 @@ +// identity_list.go defines the request and response DTOs for listing the +// current session user's bound external identities. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ListIdentitiesReq is the request for listing the current session user's bound +// external identities. +type ListIdentitiesReq struct { + g.Meta `path:"/plugins/linapro-oidc-core/identities" method:"get" tags:"External Identity" summary:"List bound external identities" dc:"Return the external identities bound to the current session user. The result is strictly self-isolated: it never exposes other accounts' linkages, so no extra permission node is required beyond authentication."` +} + +// ListIdentitiesRes is the response for listing the current session user's +// bound external identities. +type ListIdentitiesRes struct { + // Items contains the current user's bound external identities. + Items []BoundIdentityItem `json:"items" dc:"Bound external identities of the current session user"` +} + +// BoundIdentityItem is one bound external identity projection. +type BoundIdentityItem struct { + Provider string `json:"provider" dc:"Stable external provider ID, e.g. google or discord" eg:"google"` + Subject string `json:"subject" dc:"Immutable provider-issued subject identifier" eg:"110169484474386276334"` + Email string `json:"email" dc:"Email snapshot captured at link time for display only; may be empty for email-less providers" eg:"user@example.com"` +} diff --git a/linapro-oidc-core/backend/api/identity/v1/identity_unbind.go b/linapro-oidc-core/backend/api/identity/v1/identity_unbind.go new file mode 100644 index 00000000..3f87a62f --- /dev/null +++ b/linapro-oidc-core/backend/api/identity/v1/identity_unbind.go @@ -0,0 +1,17 @@ +// identity_unbind.go defines the request and response DTOs for unbinding one of +// the current session user's external identities. + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// UnbindIdentityReq is the request for unbinding one of the current session +// user's external identities. +type UnbindIdentityReq struct { + g.Meta `path:"/plugins/linapro-oidc-core/identities" method:"delete" tags:"External Identity" summary:"Unbind an external identity" dc:"Remove one external identity linkage from the current session user. Unbinding only acts on the current session user's own linkages; a linkage that does not belong to the current user is reported as not found without leaking other accounts' linkage existence. Unbinding frees the (provider, subject) key for a future relink."` + Provider string `json:"provider" v:"required|length:1,64" dc:"Stable external provider ID of the linkage to remove" eg:"google"` + Subject string `json:"subject" v:"required|length:1,191" dc:"Immutable provider-issued subject identifier of the linkage to remove" eg:"110169484474386276334"` +} + +// UnbindIdentityRes is the response for unbinding one external identity. +type UnbindIdentityRes struct{} diff --git a/linapro-oidc-core/backend/internal/controller/identity/identity.go b/linapro-oidc-core/backend/internal/controller/identity/identity.go new file mode 100644 index 00000000..6354956f --- /dev/null +++ b/linapro-oidc-core/backend/internal/controller/identity/identity.go @@ -0,0 +1,5 @@ +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package identity diff --git a/linapro-oidc-core/backend/internal/controller/identity/identity_new.go b/linapro-oidc-core/backend/internal/controller/identity/identity_new.go new file mode 100644 index 00000000..6e06932b --- /dev/null +++ b/linapro-oidc-core/backend/internal/controller/identity/identity_new.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package identity + +import ( + "lina-core/pkg/plugin/capability/bizctxcap" + "lina-plugin-linapro-oidc-core/backend/api/identity" + identitysvc "lina-plugin-linapro-oidc-core/backend/internal/service/identity" +) + +// ControllerV1 handles the current-user external identity binding API. +type ControllerV1 struct { + identitySvc *identitysvc.Service // external identity linkage service + bizCtxSvc bizctxcap.Service // current request business context +} + +// NewV1 creates and returns a new external identity controller instance. +func NewV1(identitySvc *identitysvc.Service, bizCtxSvc bizctxcap.Service) identity.IIdentityV1 { + return &ControllerV1{identitySvc: identitySvc, bizCtxSvc: bizCtxSvc} +} diff --git a/linapro-oidc-core/backend/internal/controller/identity/identity_v1_bind_identity.go b/linapro-oidc-core/backend/internal/controller/identity/identity_v1_bind_identity.go new file mode 100644 index 00000000..4aab81d9 --- /dev/null +++ b/linapro-oidc-core/backend/internal/controller/identity/identity_v1_bind_identity.go @@ -0,0 +1,33 @@ +package identity + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi" + v1 "lina-plugin-linapro-oidc-core/backend/api/identity/v1" +) + +// apiBindPluginID stamps API-driven binds with the plugin that owns the linkage +// storage; OAuth-flow binds are stamped with the verifying plugin by the host. +const apiBindPluginID = "linapro-oidc-core" + +// BindIdentity links one verified external identity to the current session user. +func (c *ControllerV1) BindIdentity(ctx context.Context, req *v1.BindIdentityReq) (res *v1.BindIdentityRes, err error) { + current := c.bizCtxSvc.Current(ctx) + if current.UserID <= 0 { + return nil, gerror.NewCode(gcode.CodeNotAuthorized) + } + if err = c.identitySvc.Bind(ctx, externalidentityspi.BindInput{ + UserID: current.UserID, + Provider: req.Provider, + Subject: req.Subject, + Email: req.Email, + PluginID: apiBindPluginID, + }); err != nil { + return nil, err + } + return &v1.BindIdentityRes{}, nil +} diff --git a/linapro-oidc-core/backend/internal/controller/identity/identity_v1_list_identities.go b/linapro-oidc-core/backend/internal/controller/identity/identity_v1_list_identities.go new file mode 100644 index 00000000..d1896144 --- /dev/null +++ b/linapro-oidc-core/backend/internal/controller/identity/identity_v1_list_identities.go @@ -0,0 +1,31 @@ +package identity + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + v1 "lina-plugin-linapro-oidc-core/backend/api/identity/v1" +) + +// ListIdentities returns the current session user's bound external identities. +func (c *ControllerV1) ListIdentities(ctx context.Context, _ *v1.ListIdentitiesReq) (res *v1.ListIdentitiesRes, err error) { + current := c.bizCtxSvc.Current(ctx) + if current.UserID <= 0 { + return nil, gerror.NewCode(gcode.CodeNotAuthorized) + } + identities, err := c.identitySvc.List(ctx, current.UserID) + if err != nil { + return nil, err + } + items := make([]v1.BoundIdentityItem, 0, len(identities)) + for _, identity := range identities { + items = append(items, v1.BoundIdentityItem{ + Provider: identity.Provider, + Subject: identity.Subject, + Email: identity.Email, + }) + } + return &v1.ListIdentitiesRes{Items: items}, nil +} diff --git a/linapro-oidc-core/backend/internal/controller/identity/identity_v1_unbind_identity.go b/linapro-oidc-core/backend/internal/controller/identity/identity_v1_unbind_identity.go new file mode 100644 index 00000000..93a426df --- /dev/null +++ b/linapro-oidc-core/backend/internal/controller/identity/identity_v1_unbind_identity.go @@ -0,0 +1,27 @@ +package identity + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi" + v1 "lina-plugin-linapro-oidc-core/backend/api/identity/v1" +) + +// UnbindIdentity removes one of the current session user's external identities. +func (c *ControllerV1) UnbindIdentity(ctx context.Context, req *v1.UnbindIdentityReq) (res *v1.UnbindIdentityRes, err error) { + current := c.bizCtxSvc.Current(ctx) + if current.UserID <= 0 { + return nil, gerror.NewCode(gcode.CodeNotAuthorized) + } + if err = c.identitySvc.Unbind(ctx, externalidentityspi.UnbindInput{ + UserID: current.UserID, + Provider: req.Provider, + Subject: req.Subject, + }); err != nil { + return nil, err + } + return &v1.UnbindIdentityRes{}, nil +} diff --git a/linapro-oidc-core/backend/internal/dao/internal/user_external_identity.go b/linapro-oidc-core/backend/internal/dao/internal/user_external_identity.go new file mode 100644 index 00000000..8b9232c5 --- /dev/null +++ b/linapro-oidc-core/backend/internal/dao/internal/user_external_identity.go @@ -0,0 +1,95 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// UserExternalIdentityDao is the data access object for the table user_external_identity. +type UserExternalIdentityDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns UserExternalIdentityColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// UserExternalIdentityColumns defines and stores column names for the table user_external_identity. +type UserExternalIdentityColumns struct { + Id string // External identity linkage ID + UserId string // Linked local user ID + Provider string // Stable external provider ID owned by the calling plugin, e.g. google, discord + Subject string // Immutable provider-issued subject identifier, e.g. OIDC sub + PluginId string // Calling plugin ID stamped by the host when the linkage was created + EmailSnapshot string // Email captured at link time for audit only, never used as a resolution key + CreatedAt string // Creation time + UpdatedAt string // Update time + DeletedAt string // Soft delete time; live rows keep NULL +} + +// userExternalIdentityColumns holds the columns for the table user_external_identity. +var userExternalIdentityColumns = UserExternalIdentityColumns{ + Id: "id", + UserId: "user_id", + Provider: "provider", + Subject: "subject", + PluginId: "plugin_id", + EmailSnapshot: "email_snapshot", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + DeletedAt: "deleted_at", +} + +// NewUserExternalIdentityDao creates and returns a new DAO object for table data access. +func NewUserExternalIdentityDao(handlers ...gdb.ModelHandler) *UserExternalIdentityDao { + return &UserExternalIdentityDao{ + group: "default", + table: "user_external_identity", + columns: userExternalIdentityColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *UserExternalIdentityDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *UserExternalIdentityDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *UserExternalIdentityDao) Columns() UserExternalIdentityColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *UserExternalIdentityDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *UserExternalIdentityDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *UserExternalIdentityDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/linapro-oidc-core/backend/internal/dao/user_external_identity.go b/linapro-oidc-core/backend/internal/dao/user_external_identity.go new file mode 100644 index 00000000..03c065c5 --- /dev/null +++ b/linapro-oidc-core/backend/internal/dao/user_external_identity.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "lina-plugin-linapro-oidc-core/backend/internal/dao/internal" +) + +// userExternalIdentityDao is the data access object for the table user_external_identity. +// You can define custom methods on it to extend its functionality as needed. +type userExternalIdentityDao struct { + *internal.UserExternalIdentityDao +} + +var ( + // UserExternalIdentity is a globally accessible object for table user_external_identity operations. + UserExternalIdentity = userExternalIdentityDao{internal.NewUserExternalIdentityDao()} +) + +// Add your custom methods and functionality below. diff --git a/linapro-oidc-core/backend/internal/model/do/user_external_identity.go b/linapro-oidc-core/backend/internal/model/do/user_external_identity.go new file mode 100644 index 00000000..897e6890 --- /dev/null +++ b/linapro-oidc-core/backend/internal/model/do/user_external_identity.go @@ -0,0 +1,25 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// UserExternalIdentity is the golang structure of table user_external_identity for DAO operations like Where/Data. +type UserExternalIdentity struct { + g.Meta `orm:"table:user_external_identity, do:true"` + Id any // External identity linkage ID + UserId any // Linked local user ID + Provider any // Stable external provider ID owned by the calling plugin, e.g. google, discord + Subject any // Immutable provider-issued subject identifier, e.g. OIDC sub + PluginId any // Calling plugin ID stamped by the host when the linkage was created + EmailSnapshot any // Email captured at link time for audit only, never used as a resolution key + CreatedAt *time.Time // Creation time + UpdatedAt *time.Time // Update time + DeletedAt *time.Time // Soft delete time; live rows keep NULL +} diff --git a/linapro-oidc-core/backend/internal/model/entity/user_external_identity.go b/linapro-oidc-core/backend/internal/model/entity/user_external_identity.go new file mode 100644 index 00000000..a5c7088a --- /dev/null +++ b/linapro-oidc-core/backend/internal/model/entity/user_external_identity.go @@ -0,0 +1,22 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "time" +) + +// UserExternalIdentity is the golang structure for table user_external_identity. +type UserExternalIdentity struct { + Id int64 `json:"id" orm:"id" description:"External identity linkage ID"` + UserId int `json:"userId" orm:"user_id" description:"Linked local user ID"` + Provider string `json:"provider" orm:"provider" description:"Stable external provider ID owned by the calling plugin, e.g. google, discord"` + Subject string `json:"subject" orm:"subject" description:"Immutable provider-issued subject identifier, e.g. OIDC sub"` + PluginId string `json:"pluginId" orm:"plugin_id" description:"Calling plugin ID stamped by the host when the linkage was created"` + EmailSnapshot string `json:"emailSnapshot" orm:"email_snapshot" description:"Email captured at link time for audit only, never used as a resolution key"` + CreatedAt *time.Time `json:"createdAt" orm:"created_at" description:"Creation time"` + UpdatedAt *time.Time `json:"updatedAt" orm:"updated_at" description:"Update time"` + DeletedAt *time.Time `json:"deletedAt" orm:"deleted_at" description:"Soft delete time; live rows keep NULL"` +} diff --git a/linapro-oidc-core/backend/internal/service/identity/identity.go b/linapro-oidc-core/backend/internal/service/identity/identity.go new file mode 100644 index 00000000..ce0051c3 --- /dev/null +++ b/linapro-oidc-core/backend/internal/service/identity/identity.go @@ -0,0 +1,285 @@ +// Package identity implements the linapro-oidc-core external-identity provider: +// (provider, subject) linkage storage, host-delegated least-privilege account +// provisioning policy, and current-user bind/unbind/list operations. +// +// Data permission boundary (self-isolation exception, see design D3): login +// resolution uses only the authoritative (provider, subject) unique key and +// reports a uniform not-found outcome, never leaking whether an email exists on +// another account. Bind, Unbind, and List act exclusively on the current +// session user's own linkages; cross-user targets are rejected as a whole. +// +// Transaction contract (design D6/D8): host account provisioning and the plugin +// linkage write cannot share one database transaction across module boundaries. +// Correctness converges on the (provider, subject) partial unique index: the +// account is provisioned first, then the linkage is inserted; a unique-index +// conflict from a concurrent provision is absorbed by re-resolving and reusing +// the winning linkage instead of surfacing a 500. Username-anchor derivation is +// deterministic per (provider, subject), so a losing provision attempt reuses +// the same host account rather than minting duplicates. +package identity + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/bizerr" + "lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi" + "lina-core/pkg/plugin/capability/usercap" + "lina-plugin-linapro-oidc-core/backend/internal/dao" + "lina-plugin-linapro-oidc-core/backend/internal/model/do" + "lina-plugin-linapro-oidc-core/backend/internal/model/entity" +) + +// usernameAnchorPrefix namespaces derived username anchors so provisioned +// accounts are recognizable in host user management. +const usernameAnchorPrefix = "oidc-" + +// usernameAnchorHashLength is the hex length kept from the identity digest. 16 +// hex characters (64 bits) are collision-resistant for account-scale +// cardinality while keeping the derived username within the host's 30-character +// provisioning budget. +const usernameAnchorHashLength = 16 + +// Service implements the externalidentityspi.Provider contract on top of the +// plugin-owned user_external_identity table and the host user capability. +type Service struct { + users usercap.Service +} + +// Ensure Service implements the host external-identity provider SPI. +var _ externalidentityspi.Provider = (*Service)(nil) + +// New creates the linapro-oidc-core external-identity provider from the +// injected host user capability. +func New(users usercap.Service) (*Service, error) { + if users == nil { + return nil, gerror.New("linapro-oidc-core identity provider requires host user capability") + } + return &Service{users: users}, nil +} + +// Resolve maps a verified (provider, subject) pair to a linked local user ID +// through the authoritative partial unique index. Missing linkage returns +// found=false without error so the host keeps a uniform not-provisioned +// outcome. +func (s *Service) Resolve(ctx context.Context, in externalidentityspi.ResolveInput) (int, bool, error) { + provider := strings.TrimSpace(in.Provider) + subject := strings.TrimSpace(in.Subject) + if provider == "" || subject == "" { + return 0, false, bizerr.NewCode(CodeIdentityInvalid) + } + linkage, err := s.findLinkage(ctx, provider, subject) + if err != nil { + return 0, false, err + } + if linkage == nil { + return 0, false, nil + } + return linkage.UserId, true, nil +} + +// Provision runs the plugin-owned auto-provisioning policy for one unlinked +// verified identity: idempotent (provider, subject) reuse, same-email conflict +// rejection, email-less deterministic anchor derivation, and host-delegated +// least-privilege account creation followed by the linkage write. +func (s *Service) Provision(ctx context.Context, in externalidentityspi.ProvisionInput) (int, error) { + provider := strings.TrimSpace(in.Provider) + subject := strings.TrimSpace(in.Subject) + if provider == "" || subject == "" { + return 0, bizerr.NewCode(CodeIdentityInvalid) + } + if !in.AllowAutoProvision { + return 0, bizerr.NewCode(CodeProvisionNotAllowed) + } + if s == nil || s.users == nil { + return 0, bizerr.NewCode(CodeUserCapabilityUnavailable) + } + // Idempotent fast path: a concurrent or earlier provision already linked + // this identity. + if linkage, err := s.findLinkage(ctx, provider, subject); err != nil { + return 0, err + } else if linkage != nil { + return linkage.UserId, nil + } + email := strings.TrimSpace(in.Email) + anchor := strings.TrimSpace(in.UsernameAnchor) + if email == "" && anchor == "" { + // Email-less provisioning derives a deterministic, collision-resistant + // anchor from the identity key so the same identity always reaches the + // same derived username, and distinct identities cannot alias. + anchor = deriveUsernameAnchor(provider, subject) + } + userID, err := s.provisionAccount(ctx, provider, email, anchor, in.DisplayName) + if err != nil { + return 0, err + } + if _, err = dao.UserExternalIdentity.Ctx(ctx).Data(do.UserExternalIdentity{ + UserId: userID, + Provider: provider, + Subject: subject, + PluginId: strings.TrimSpace(in.PluginID), + EmailSnapshot: email, + }).Insert(); err != nil { + // A unique-index conflict means a concurrent provision won the race. + // Reuse the winning linkage idempotently instead of bubbling a 500; a + // losing provision attempt reused the same deterministic account, so no + // duplicate account is minted. + if linkage, findErr := s.findLinkage(ctx, provider, subject); findErr == nil && linkage != nil { + return linkage.UserId, nil + } + return 0, bizerr.WrapCode(err, CodeIdentityWriteFailed) + } + return userID, nil +} + +// Bind links a verified external identity to the current session user. It only +// acts on the current user: a (provider, subject) already owned by another +// account is rejected with a conflict, and re-binding an identity the current +// user already owns succeeds idempotently. +func (s *Service) Bind(ctx context.Context, in externalidentityspi.BindInput) error { + provider := strings.TrimSpace(in.Provider) + subject := strings.TrimSpace(in.Subject) + if in.UserID <= 0 || provider == "" || subject == "" { + return bizerr.NewCode(CodeIdentityInvalid) + } + linkage, err := s.findLinkage(ctx, provider, subject) + if err != nil { + return err + } + if linkage != nil { + if linkage.UserId == in.UserID { + return nil + } + return bizerr.NewCode(CodeBindConflict) + } + if _, err = dao.UserExternalIdentity.Ctx(ctx).Data(do.UserExternalIdentity{ + UserId: in.UserID, + Provider: provider, + Subject: subject, + PluginId: strings.TrimSpace(in.PluginID), + EmailSnapshot: strings.TrimSpace(in.Email), + }).Insert(); err != nil { + // Absorb a concurrent bind race through the unique index: same-user + // winner keeps idempotent success, another owner reports conflict. + if existing, findErr := s.findLinkage(ctx, provider, subject); findErr == nil && existing != nil { + if existing.UserId == in.UserID { + return nil + } + return bizerr.NewCode(CodeBindConflict) + } + return bizerr.WrapCode(err, CodeIdentityWriteFailed) + } + return nil +} + +// Unbind removes one of the current session user's external-identity links. A +// linkage that does not belong to the current user reports not-found without +// leaking other accounts' linkage existence. Soft deletion frees the partial +// unique index for a future relink. +func (s *Service) Unbind(ctx context.Context, in externalidentityspi.UnbindInput) error { + provider := strings.TrimSpace(in.Provider) + subject := strings.TrimSpace(in.Subject) + if in.UserID <= 0 || provider == "" || subject == "" { + return bizerr.NewCode(CodeIdentityInvalid) + } + result, err := dao.UserExternalIdentity.Ctx(ctx).Where(do.UserExternalIdentity{ + UserId: in.UserID, + Provider: provider, + Subject: subject, + }).Delete() + if err != nil { + return bizerr.WrapCode(err, CodeIdentityWriteFailed) + } + affected, err := result.RowsAffected() + if err != nil { + return bizerr.WrapCode(err, CodeIdentityWriteFailed) + } + if affected == 0 { + return bizerr.NewCode(CodeIdentityNotFound) + } + return nil +} + +// List returns the current session user's bound external identities, strictly +// self-isolated by the caller-supplied session user ID. +func (s *Service) List(ctx context.Context, userID int) ([]externalidentityspi.BoundIdentity, error) { + if userID <= 0 { + return []externalidentityspi.BoundIdentity{}, nil + } + var rows []*entity.UserExternalIdentity + if err := dao.UserExternalIdentity.Ctx(ctx). + Where(do.UserExternalIdentity{UserId: userID}). + OrderAsc(dao.UserExternalIdentity.Columns().Provider). + OrderAsc(dao.UserExternalIdentity.Columns().Subject). + Scan(&rows); err != nil { + return nil, bizerr.WrapCode(err, CodeIdentityQueryFailed) + } + identities := make([]externalidentityspi.BoundIdentity, 0, len(rows)) + for _, row := range rows { + if row == nil { + continue + } + identities = append(identities, externalidentityspi.BoundIdentity{ + Provider: row.Provider, + Subject: row.Subject, + Email: row.EmailSnapshot, + }) + } + return identities, nil +} + +// findLinkage returns the live linkage row for one (provider, subject) key, or +// nil when none exists. Soft-deleted rows are filtered automatically. +func (s *Service) findLinkage(ctx context.Context, provider string, subject string) (*entity.UserExternalIdentity, error) { + var linkage *entity.UserExternalIdentity + if err := dao.UserExternalIdentity.Ctx(ctx).Where(do.UserExternalIdentity{ + Provider: provider, + Subject: subject, + }).Scan(&linkage); err != nil { + return nil, bizerr.WrapCode(err, CodeIdentityQueryFailed) + } + return linkage, nil +} + +// provisionAccount mints one least-privilege host account through the user +// capability and parses the returned domain ID. Same-email conflict policy: +// the login path carries no actor context, so the unfiltered email-existence +// check is enforced by the host minting primitive itself; this provider maps +// the capability sentinel into its caller-visible conflict code, rejecting +// silent auto-provisioning so the user signs in to the existing account and +// binds the identity explicitly instead of enabling an email-assertion +// takeover. +func (s *Service) provisionAccount(ctx context.Context, provider string, email string, anchor string, displayName string) (int, error) { + provisionedID, err := s.users.ProvisionExternal(ctx, usercap.ProvisionExternalInput{ + Email: email, + DisplayName: strings.TrimSpace(displayName), + Remark: "auto-provisioned by external login provider " + provider, + UsernameAnchor: anchor, + }) + if err != nil { + if errors.Is(err, usercap.ErrProvisionEmailConflict) { + return 0, bizerr.NewCode(CodeProvisionEmailConflict) + } + return 0, bizerr.WrapCode(err, CodeProvisionFailed) + } + userID, err := strconv.Atoi(string(provisionedID)) + if err != nil || userID <= 0 { + return 0, bizerr.NewCode(CodeProvisionFailed) + } + return userID, nil +} + +// deriveUsernameAnchor derives the deterministic, collision-resistant username +// anchor for one email-less external identity. The digest covers provider and +// subject with a separator so distinct identities cannot produce colliding +// anchors through concatenation ambiguity. +func deriveUsernameAnchor(provider string, subject string) string { + digest := sha256.Sum256([]byte(provider + "\x00" + subject)) + return usernameAnchorPrefix + hex.EncodeToString(digest[:])[:usernameAnchorHashLength] +} diff --git a/linapro-oidc-core/backend/internal/service/identity/identity_code.go b/linapro-oidc-core/backend/internal/service/identity/identity_code.go new file mode 100644 index 00000000..f831b0e1 --- /dev/null +++ b/linapro-oidc-core/backend/internal/service/identity/identity_code.go @@ -0,0 +1,73 @@ +// This file defines linapro-oidc-core business error codes and runtime i18n metadata. + +package identity + +import ( + "github.com/gogf/gf/v2/errors/gcode" + + "lina-core/pkg/bizerr" +) + +var ( + // CodeIdentityInvalid reports that the external identity key is invalid. + CodeIdentityInvalid = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_IDENTITY_INVALID", + "External identity provider and subject cannot be empty", + gcode.CodeInvalidParameter, + ) + // CodeIdentityQueryFailed reports that querying identity linkages failed. + CodeIdentityQueryFailed = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_IDENTITY_QUERY_FAILED", + "Failed to query external identity linkage", + gcode.CodeInternalError, + ) + // CodeIdentityWriteFailed reports that writing an identity linkage failed. + CodeIdentityWriteFailed = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_IDENTITY_WRITE_FAILED", + "Failed to write external identity linkage", + gcode.CodeInternalError, + ) + // CodeIdentityNotFound reports that the target linkage does not exist for + // the current user. It deliberately does not distinguish "absent" from + // "owned by another account" so unbinding never leaks other accounts' + // linkage existence. + CodeIdentityNotFound = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_IDENTITY_NOT_FOUND", + "The external identity is not bound to the current account", + gcode.CodeNotFound, + ) + // CodeBindConflict reports that the external identity is already bound to + // another account. + CodeBindConflict = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_BIND_CONFLICT", + "The external identity is already bound to another account", + gcode.CodeInvalidOperation, + ) + // CodeProvisionNotAllowed reports that auto-provisioning is not permitted + // for this login. + CodeProvisionNotAllowed = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_PROVISION_NOT_ALLOWED", + "Automatic account provisioning is not enabled for this login", + gcode.CodeInvalidOperation, + ) + // CodeProvisionEmailConflict reports that an existing local account already + // uses the asserted email, so silent auto-provisioning is rejected. The user + // must sign in to the existing account and bind the identity explicitly. + CodeProvisionEmailConflict = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_PROVISION_EMAIL_CONFLICT", + "An account with the same email already exists. Sign in to that account and bind this identity instead", + gcode.CodeInvalidOperation, + ) + // CodeProvisionFailed reports that least-privilege account provisioning failed. + CodeProvisionFailed = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_PROVISION_FAILED", + "Failed to provision an account for the external identity", + gcode.CodeInternalError, + ) + // CodeUserCapabilityUnavailable reports that the host user capability is missing. + CodeUserCapabilityUnavailable = bizerr.MustDefine( + "PLUGIN_OIDC_CORE_USER_CAPABILITY_UNAVAILABLE", + "Host user capability is unavailable", + gcode.CodeInvalidOperation, + ) +) diff --git a/linapro-oidc-core/backend/internal/service/identity/identity_test.go b/linapro-oidc-core/backend/internal/service/identity/identity_test.go new file mode 100644 index 00000000..e6b5e0e3 --- /dev/null +++ b/linapro-oidc-core/backend/internal/service/identity/identity_test.go @@ -0,0 +1,398 @@ +// This file verifies the linapro-oidc-core external-identity provider: +// (provider, subject) resolution, idempotent provisioning, same-email conflict +// rejection, email-less deterministic anchor derivation, concurrent +// unique-index conflict absorption, and self-isolated bind/unbind/list. These +// are database-backed tests following the plugin test convention: each test +// configures the local PostgreSQL database, ensures the plugin table exists, +// creates unique fixtures, and cleans up its own rows. + +package identity + +import ( + "context" + "fmt" + "strconv" + "testing" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + "lina-core/pkg/bizerr" + _ "lina-core/pkg/dbdriver" + "lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi" + "lina-core/pkg/plugin/capability/capmodel" + "lina-core/pkg/plugin/capability/usercap" + "lina-core/pkg/statusflag" + "lina-plugin-linapro-oidc-core/backend/internal/dao" + "lina-plugin-linapro-oidc-core/backend/internal/model/do" +) + +// fakeUserCapability is a configurable usercap.Service double. Only +// ProvisionExternal carries behavior; the remaining methods are +// interface-conformance no-ops. +type fakeUserCapability struct { + // provisionID is returned by ProvisionExternal. + provisionID int + // provisionErr fails ProvisionExternal when set. + provisionErr error + // provisionCalls records every provisioning request. + provisionCalls []usercap.ProvisionExternalInput + // beforeProvisionReturn runs after recording a provisioning call and before + // returning, letting tests simulate a concurrent provision winning the race. + beforeProvisionReturn func() +} + +func (f *fakeUserCapability) Current(context.Context) (*usercap.UserInfo, error) { return nil, nil } +func (f *fakeUserCapability) Get(context.Context, usercap.UserID) (*usercap.UserInfo, error) { + return nil, nil +} +func (f *fakeUserCapability) BatchGet(context.Context, []usercap.UserID) (*capmodel.BatchResult[*usercap.UserInfo, usercap.UserID], error) { + return &capmodel.BatchResult[*usercap.UserInfo, usercap.UserID]{}, nil +} + +// BatchResolve is an interface-conformance no-op: the login path carries no +// actor context, so the provider never performs data-scoped lookups. +func (f *fakeUserCapability) BatchResolve(context.Context, usercap.BatchResolveInput) (*capmodel.BatchResult[*usercap.UserInfo, usercap.ResolveKey], error) { + return &capmodel.BatchResult[*usercap.UserInfo, usercap.ResolveKey]{}, nil +} +func (f *fakeUserCapability) List(context.Context, usercap.ListInput) (*capmodel.PageResult[*usercap.UserInfo], error) { + return &capmodel.PageResult[*usercap.UserInfo]{}, nil +} +func (f *fakeUserCapability) EnsureVisible(context.Context, []usercap.UserID) error { return nil } +func (f *fakeUserCapability) Create(context.Context, usercap.CreateInput) (usercap.UserID, error) { + return "", nil +} + +// ProvisionExternal records the call and returns the configured outcome. +func (f *fakeUserCapability) ProvisionExternal(_ context.Context, input usercap.ProvisionExternalInput) (usercap.UserID, error) { + f.provisionCalls = append(f.provisionCalls, input) + if f.provisionErr != nil { + return "", f.provisionErr + } + if f.beforeProvisionReturn != nil { + f.beforeProvisionReturn() + } + return usercap.UserID(strconv.Itoa(f.provisionID)), nil +} +func (f *fakeUserCapability) Update(context.Context, usercap.UpdateInput) error { return nil } +func (f *fakeUserCapability) Delete(context.Context, usercap.UserID) error { return nil } +func (f *fakeUserCapability) SetStatus(context.Context, usercap.UserID, statusflag.Enabled) error { + return nil +} +func (f *fakeUserCapability) ResetPassword(context.Context, usercap.UserID, string) error { + return nil +} +func (f *fakeUserCapability) Assignment() usercap.AssignmentService { return nil } + +// configureIdentityTestDB points the package test at the local PostgreSQL +// database and ensures the plugin-owned table exists. +func configureIdentityTestDB(t *testing.T, ctx context.Context) { + t.Helper() + originalConfig := gdb.GetAllConfig() + if err := gdb.SetConfig(gdb.Config{ + gdb.DefaultGroupName: gdb.ConfigGroup{{Link: "pgsql:postgres:postgres@tcp(127.0.0.1:5432)/linapro?sslmode=disable"}}, + }); err != nil { + t.Fatalf("configure identity test database failed: %v", err) + } + db := g.DB() + if _, err := db.Exec(ctx, ` +CREATE TABLE IF NOT EXISTS user_external_identity ( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "user_id" INT NOT NULL, + "provider" VARCHAR(64) NOT NULL, + "subject" VARCHAR(191) NOT NULL, + "plugin_id" VARCHAR(128) NOT NULL DEFAULT '', + "email_snapshot" VARCHAR(191) NOT NULL DEFAULT '', + "created_at" TIMESTAMPTZ, + "updated_at" TIMESTAMPTZ, + "deleted_at" TIMESTAMPTZ +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_external_identity_provider_subject + ON user_external_identity ("provider", "subject") WHERE "deleted_at" IS NULL; +CREATE INDEX IF NOT EXISTS idx_user_external_identity_user + ON user_external_identity ("user_id"); +`); err != nil { + t.Fatalf("ensure identity test table failed: %v", err) + } + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Errorf("close identity test database failed: %v", err) + } + if err := gdb.SetConfig(originalConfig); err != nil { + t.Errorf("restore identity test database config failed: %v", err) + } + }) +} + +// cleanupIdentityRows removes every linkage row for one provider during cleanup. +func cleanupIdentityRows(t *testing.T, ctx context.Context, provider string) { + t.Helper() + t.Cleanup(func() { + if _, err := dao.UserExternalIdentity.Ctx(ctx). + Unscoped(). + Where(do.UserExternalIdentity{Provider: provider}). + Delete(); err != nil { + t.Errorf("cleanup identity rows failed: %v", err) + } + }) +} + +// uniqueTestProvider returns a per-test provider ID so tests stay independent +// and order-free even against a shared local database. +func uniqueTestProvider(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +// newTestService builds the identity service on the supplied user capability. +func newTestService(t *testing.T, users usercap.Service) *Service { + t.Helper() + svc, err := New(users) + if err != nil { + t.Fatalf("new identity service: %v", err) + } + return svc +} + +// TestResolveMissingLinkageReturnsNotFound verifies resolution reports +// found=false without error for an unlinked identity, and rejects empty keys. +func TestResolveMissingLinkageReturnsNotFound(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + svc := newTestService(t, &fakeUserCapability{}) + + if _, _, err := svc.Resolve(ctx, externalidentityspi.ResolveInput{Provider: " ", Subject: "s"}); !bizerr.Is(err, CodeIdentityInvalid) { + t.Fatalf("blank provider: expected identity-invalid, got %v", err) + } + _, found, err := svc.Resolve(ctx, externalidentityspi.ResolveInput{ + Provider: uniqueTestProvider("resolve-missing"), + Subject: "subject-1", + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if found { + t.Fatal("expected found=false for unlinked identity") + } +} + +// TestProvisionReusesExistingLinkageIdempotently verifies a linked identity is +// reused without any account provisioning call. +func TestProvisionReusesExistingLinkageIdempotently(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + users := &fakeUserCapability{provisionID: 999} + svc := newTestService(t, users) + + provider := uniqueTestProvider("prov-idem") + cleanupIdentityRows(t, ctx, provider) + if _, err := dao.UserExternalIdentity.Ctx(ctx).Data(do.UserExternalIdentity{ + UserId: 42, Provider: provider, Subject: "subject-1", PluginId: "test", + }).Insert(); err != nil { + t.Fatalf("seed linkage: %v", err) + } + + userID, err := svc.Provision(ctx, externalidentityspi.ProvisionInput{ + Provider: provider, Subject: "subject-1", Email: "idem@example.com", AllowAutoProvision: true, + }) + if err != nil { + t.Fatalf("provision: %v", err) + } + if userID != 42 { + t.Fatalf("expected linked user 42, got %d", userID) + } + if len(users.provisionCalls) != 0 { + t.Fatalf("expected no account provisioning, got %d calls", len(users.provisionCalls)) + } +} + +// TestProvisionRejectsSameEmailConflict verifies the host minting primitive's +// email-conflict sentinel is mapped into the provider's caller-visible +// conflict code and never writes a linkage. The unfiltered email-existence +// check lives in the host primitive because the login path has no actor +// context for data-scoped lookups. +func TestProvisionRejectsSameEmailConflict(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + email := "conflict@example.com" + users := &fakeUserCapability{provisionErr: usercap.ErrProvisionEmailConflict} + svc := newTestService(t, users) + + provider := uniqueTestProvider("prov-conflict") + cleanupIdentityRows(t, ctx, provider) + _, err := svc.Provision(ctx, externalidentityspi.ProvisionInput{ + Provider: provider, Subject: "subject-1", Email: email, AllowAutoProvision: true, + }) + if !bizerr.Is(err, CodeProvisionEmailConflict) { + t.Fatalf("expected email-conflict, got %v", err) + } + // No linkage row may exist for the conflicted identity. + if _, found, resolveErr := svc.Resolve(ctx, externalidentityspi.ResolveInput{ + Provider: provider, Subject: "subject-1", + }); resolveErr != nil || found { + t.Fatalf("expected no linkage after conflict, got found=%v err=%v", found, resolveErr) + } +} + +// TestProvisionDisallowedFailsClosed verifies AllowAutoProvision=false rejects +// without provisioning. +func TestProvisionDisallowedFailsClosed(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + users := &fakeUserCapability{provisionID: 7} + svc := newTestService(t, users) + + _, err := svc.Provision(ctx, externalidentityspi.ProvisionInput{ + Provider: uniqueTestProvider("prov-off"), Subject: "subject-1", Email: "off@example.com", + }) + if !bizerr.Is(err, CodeProvisionNotAllowed) { + t.Fatalf("expected provision-not-allowed, got %v", err) + } + if len(users.provisionCalls) != 0 { + t.Fatalf("expected no account provisioning, got %d calls", len(users.provisionCalls)) + } +} + +// TestProvisionEmaillessDerivesDeterministicAnchor verifies email-less +// provisioning derives a deterministic, collision-resistant username anchor and +// never triggers the email-conflict policy. +func TestProvisionEmaillessDerivesDeterministicAnchor(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + users := &fakeUserCapability{provisionID: 77} + svc := newTestService(t, users) + + provider := uniqueTestProvider("prov-anchor") + cleanupIdentityRows(t, ctx, provider) + userID, err := svc.Provision(ctx, externalidentityspi.ProvisionInput{ + Provider: provider, Subject: "wechat-subject", AllowAutoProvision: true, PluginID: "linapro-oidc-test", + }) + if err != nil { + t.Fatalf("email-less provision: %v", err) + } + if userID != 77 { + t.Fatalf("expected provisioned user 77, got %d", userID) + } + if len(users.provisionCalls) != 1 { + t.Fatalf("expected one provisioning call, got %d", len(users.provisionCalls)) + } + call := users.provisionCalls[0] + if call.Email != "" { + t.Fatalf("expected empty email, got %q", call.Email) + } + want := deriveUsernameAnchor(provider, "wechat-subject") + if call.UsernameAnchor != want { + t.Fatalf("anchor = %q, want deterministic %q", call.UsernameAnchor, want) + } + if again := deriveUsernameAnchor(provider, "wechat-subject"); again != want { + t.Fatalf("anchor derivation not deterministic: %q vs %q", again, want) + } + if other := deriveUsernameAnchor(provider, "other-subject"); other == want { + t.Fatal("distinct subjects must derive distinct anchors") + } + // The linkage row must reference the provisioned user with the stamped plugin. + resolvedID, found, err := svc.Resolve(ctx, externalidentityspi.ResolveInput{Provider: provider, Subject: "wechat-subject"}) + if err != nil || !found || resolvedID != 77 { + t.Fatalf("expected linkage to user 77, got id=%d found=%v err=%v", resolvedID, found, err) + } +} + +// TestProvisionAbsorbsConcurrentUniqueConflict verifies that when a concurrent +// provision wins the (provider, subject) unique index race, the losing call +// reuses the winning linkage instead of surfacing a 500. +func TestProvisionAbsorbsConcurrentUniqueConflict(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + provider := uniqueTestProvider("prov-race") + cleanupIdentityRows(t, ctx, provider) + + users := &fakeUserCapability{provisionID: 88} + // Simulate a concurrent provision winning the race after this call's + // fast-path linkage check but before its linkage insert. + users.beforeProvisionReturn = func() { + if _, err := dao.UserExternalIdentity.Ctx(ctx).Data(do.UserExternalIdentity{ + UserId: 55, Provider: provider, Subject: "raced-subject", PluginId: "winner", + }).Insert(); err != nil { + t.Fatalf("seed winning linkage: %v", err) + } + } + svc := newTestService(t, users) + + userID, err := svc.Provision(ctx, externalidentityspi.ProvisionInput{ + Provider: provider, Subject: "raced-subject", Email: "race@example.com", AllowAutoProvision: true, + }) + if err != nil { + t.Fatalf("expected unique conflict to be absorbed, got %v", err) + } + if userID != 55 { + t.Fatalf("expected winning linkage user 55, got %d", userID) + } +} + +// TestBindUnbindListSelfIsolation verifies bind conflict and idempotency, +// unbind self-isolation, relink after unbind, list self-isolation, and +// cross-provider subject independence. +func TestBindUnbindListSelfIsolation(t *testing.T) { + ctx := context.Background() + configureIdentityTestDB(t, ctx) + svc := newTestService(t, &fakeUserCapability{}) + + provider := uniqueTestProvider("bind") + otherProvider := uniqueTestProvider("bind-other") + cleanupIdentityRows(t, ctx, provider) + cleanupIdentityRows(t, ctx, otherProvider) + const userA, userB = 101, 202 + + // Bind for user A succeeds; re-binding the same identity is idempotent. + bind := externalidentityspi.BindInput{UserID: userA, Provider: provider, Subject: "shared-subject", Email: "a@example.com", PluginID: "test"} + if err := svc.Bind(ctx, bind); err != nil { + t.Fatalf("bind: %v", err) + } + if err := svc.Bind(ctx, bind); err != nil { + t.Fatalf("idempotent re-bind: %v", err) + } + // The same identity bound by another user is rejected as a conflict. + conflict := bind + conflict.UserID = userB + if err := svc.Bind(ctx, conflict); !bizerr.Is(err, CodeBindConflict) { + t.Fatalf("expected bind-conflict, got %v", err) + } + // The same subject under a DIFFERENT provider is a distinct identity. + if err := svc.Bind(ctx, externalidentityspi.BindInput{ + UserID: userB, Provider: otherProvider, Subject: "shared-subject", PluginID: "test", + }); err != nil { + t.Fatalf("cross-provider bind: %v", err) + } + // List is self-isolated: each user only sees their own linkage. + identitiesA, err := svc.List(ctx, userA) + if err != nil { + t.Fatalf("list A: %v", err) + } + if len(identitiesA) != 1 || identitiesA[0].Provider != provider { + t.Fatalf("user A list = %#v, want one %s linkage", identitiesA, provider) + } + identitiesB, err := svc.List(ctx, userB) + if err != nil { + t.Fatalf("list B: %v", err) + } + if len(identitiesB) != 1 || identitiesB[0].Provider != otherProvider { + t.Fatalf("user B list = %#v, want one %s linkage", identitiesB, otherProvider) + } + // Unbind by a non-owner reports not-found without leaking existence. + if err = svc.Unbind(ctx, externalidentityspi.UnbindInput{ + UserID: userB, Provider: provider, Subject: "shared-subject", + }); !bizerr.Is(err, CodeIdentityNotFound) { + t.Fatalf("cross-user unbind: expected not-found, got %v", err) + } + // Owner unbind succeeds and frees the unique key for a relink. + if err = svc.Unbind(ctx, externalidentityspi.UnbindInput{ + UserID: userA, Provider: provider, Subject: "shared-subject", + }); err != nil { + t.Fatalf("owner unbind: %v", err) + } + if err = svc.Bind(ctx, externalidentityspi.BindInput{ + UserID: userB, Provider: provider, Subject: "shared-subject", PluginID: "test", + }); err != nil { + t.Fatalf("relink after unbind: %v", err) + } +} diff --git a/linapro-oidc-core/backend/plugin.go b/linapro-oidc-core/backend/plugin.go new file mode 100644 index 00000000..5ac5dae2 --- /dev/null +++ b/linapro-oidc-core/backend/plugin.go @@ -0,0 +1,99 @@ +// Package backend wires the linapro-oidc-core source plugin into the host +// plugin registry. It declares the external-identity provider engine factory +// (resolve/provision/bind storage behind the host external-login seam) and +// registers the current-user identity binding API. OAuth protocol plugins such +// as linapro-oidc-google and linapro-oidc-discord depend on this plugin and +// keep calling the host externallogin seam unchanged. +package backend + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + + "lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi" + "lina-core/pkg/plugin/pluginhost" + pluginoidccore "lina-plugin-linapro-oidc-core" + identityctrl "lina-plugin-linapro-oidc-core/backend/internal/controller/identity" + identitysvc "lina-plugin-linapro-oidc-core/backend/internal/service/identity" +) + +// pluginID is the immutable identifier declared by the embedded plugin.yaml. +const pluginID = "linapro-oidc-core" + +// init registers the linapro-oidc-core source plugin, declares the +// external-identity provider engine factory, and registers HTTP routes. +func init() { + plugin := pluginhost.NewDeclarations(pluginID) + plugin.Assets().UseEmbeddedFiles(pluginoidccore.EmbeddedFiles) + // ProvideExternalIdentityProvider declares the engine factory only. The + // host manager lazily constructs the provider gated by plugin enablement: + // disabling this plugin immediately fails external login closed. This + // plugin deliberately declares no provider-ID ownership: it never calls + // LoginByVerifiedIdentity; provider IDs stay owned by the OAuth plugins. + // This is an allowed top-level static registration panic. + if err := plugin.Providers().ProvideExternalIdentityProvider(newProvider); 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) + } +} + +// newProvider creates the linapro-oidc-core external-identity provider from +// host-published services during lazy capability activation. +func newProvider(_ context.Context, env externalidentityspi.ProviderEnv) (externalidentityspi.Provider, error) { + if env.Users == nil { + return nil, gerror.New("linapro-oidc-core provider requires host user capability") + } + return identitysvc.New(env.Users) +} + +// registerRoutes registers the current-user identity binding API under the +// plugin API prefix, guarded by Auth+Tenancy+Permission middlewares. All +// endpoints are strictly self-isolated to the current session user (design +// D3), so they carry no extra permission nodes beyond authentication. +func registerRoutes(_ context.Context, registrar pluginhost.HTTPRegistrar) error { + var ( + routes = registrar.Routes() + middlewares = routes.Middlewares() + services = registrar.Services() + ) + if services == nil || services.Users() == nil || services.BizCtx() == nil { + return gerror.New("linapro-oidc-core routes require host user and bizctx capabilities") + } + identitySvc, err := identitysvc.New(services.Users()) + if err != nil { + return err + } + identityController := identityctrl.NewV1(identitySvc, services.BizCtx()) + 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( + identityController, + ) + }) + }) + }) + return routes.Err() +} diff --git a/linapro-oidc-core/go.mod b/linapro-oidc-core/go.mod new file mode 100644 index 00000000..52f1bc8a --- /dev/null +++ b/linapro-oidc-core/go.mod @@ -0,0 +1,42 @@ +module lina-plugin-linapro-oidc-core + +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/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 // 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/lib/pq v1.10.9 // 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-core/go.sum b/linapro-oidc-core/go.sum new file mode 100644 index 00000000..99ffaa59 --- /dev/null +++ b/linapro-oidc-core/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-core/hack/config.yaml b/linapro-oidc-core/hack/config.yaml new file mode 100644 index 00000000..41c0be7a --- /dev/null +++ b/linapro-oidc-core/hack/config.yaml @@ -0,0 +1,25 @@ +# CLI tool, only in development environment. +# https://goframe.org/docs/cli +gfcli: + gen: + dao: + - link: "pgsql:postgres:postgres@tcp(127.0.0.1:5432)/linapro?sslmode=disable" + path: "internal" + tables: "user_external_identity" + importPrefix: "lina-plugin-linapro-oidc-core/backend/internal" + descriptionTag: true + noModelComment: true + stdTime: true + typeMapping: + timestamp: + type: "*time.Time" + import: time + "timestamp without time zone": + type: "*time.Time" + import: time + timestamptz: + type: "*time.Time" + import: time + "timestamp with time zone": + type: "*time.Time" + import: time diff --git a/linapro-oidc-core/manifest/i18n/en-US/apidoc/plugin-api-main.json b/linapro-oidc-core/manifest/i18n/en-US/apidoc/plugin-api-main.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/linapro-oidc-core/manifest/i18n/en-US/apidoc/plugin-api-main.json @@ -0,0 +1 @@ +{} diff --git a/linapro-oidc-core/manifest/i18n/en-US/error.json b/linapro-oidc-core/manifest/i18n/en-US/error.json new file mode 100644 index 00000000..b5af5362 --- /dev/null +++ b/linapro-oidc-core/manifest/i18n/en-US/error.json @@ -0,0 +1,39 @@ +{ + "error": { + "plugin": { + "oidc": { + "core": { + "bind": { + "conflict": "The external identity is already bound to another account" + }, + "identity": { + "invalid": "External identity provider and subject cannot be empty", + "not": { + "found": "The external identity is not bound to the current account" + }, + "query": { + "failed": "Failed to query external identity linkage" + }, + "write": { + "failed": "Failed to write external identity linkage" + } + }, + "provision": { + "email": { + "conflict": "An account with the same email already exists. Sign in to that account and bind this identity instead" + }, + "failed": "Failed to provision an account for the external identity", + "not": { + "allowed": "Automatic account provisioning is not enabled for this login" + } + }, + "user": { + "capability": { + "unavailable": "Host user capability is unavailable" + } + } + } + } + } + } +} diff --git a/linapro-oidc-core/manifest/i18n/zh-CN/apidoc/plugin-api-main.json b/linapro-oidc-core/manifest/i18n/zh-CN/apidoc/plugin-api-main.json new file mode 100644 index 00000000..c425e070 --- /dev/null +++ b/linapro-oidc-core/manifest/i18n/zh-CN/apidoc/plugin-api-main.json @@ -0,0 +1,86 @@ +{ + "plugins": { + "linapro_oidc_core": { + "api": { + "identity": { + "v1": { + "BindIdentityReq": { + "fields": { + "email": { + "dc": "可选的已验证邮箱,仅作为审计快照记录,绝不作为解析键使用" + }, + "provider": { + "dc": "由验签 OAuth 插件拥有的稳定外部 provider 标识" + }, + "subject": { + "dc": "来自已验证身份的、由 provider 签发的不可变 subject 标识" + } + }, + "meta": { + "dc": "将一个已验证的外部身份(provider + subject)绑定到当前会话用户。调用方必须已通过对应 OAuth 插件完成身份验证;本接口不执行任何 OAuth 交换。绑定仅作用于当前会话用户,(provider, subject) 已被其他账号占用时返回冲突错误;重复绑定当前用户已拥有的身份幂等成功。", + "summary": "绑定外部身份", + "tags": "外部身份" + }, + "schema": { + "dc": "将一个已验证的外部身份(provider + subject)绑定到当前会话用户。调用方必须已通过对应 OAuth 插件完成身份验证;本接口不执行任何 OAuth 交换。绑定仅作用于当前会话用户,(provider, subject) 已被其他账号占用时返回冲突错误;重复绑定当前用户已拥有的身份幂等成功。" + } + }, + "BindIdentityRes": {}, + "BoundIdentityItem": { + "fields": { + "email": { + "dc": "绑定时记录的邮箱快照,仅用于展示;无邮箱 provider 可能为空" + }, + "provider": { + "dc": "稳定的外部 provider 标识,例如 google 或 discord" + }, + "subject": { + "dc": "由 provider 签发的不可变 subject 标识" + } + }, + "schema": { + "dc": "一条已绑定的外部身份投影" + } + }, + "ListIdentitiesReq": { + "meta": { + "dc": "返回当前会话用户已绑定的外部身份。结果严格自隔离:绝不暴露其他账号的绑定关系,因此除登录认证外无需额外权限节点。", + "summary": "列出已绑定的外部身份", + "tags": "外部身份" + }, + "schema": { + "dc": "返回当前会话用户已绑定的外部身份。结果严格自隔离:绝不暴露其他账号的绑定关系,因此除登录认证外无需额外权限节点。" + } + }, + "ListIdentitiesRes": { + "fields": { + "items": { + "dc": "当前会话用户已绑定的外部身份列表" + } + } + }, + "UnbindIdentityReq": { + "fields": { + "provider": { + "dc": "要解绑链接的稳定外部 provider 标识" + }, + "subject": { + "dc": "要解绑链接的、由 provider 签发的不可变 subject 标识" + } + }, + "meta": { + "dc": "从当前会话用户移除一条外部身份链接。解绑仅作用于当前会话用户自己的链接;不属于当前用户的链接按不存在处理,不泄露其他账号的链接存在性。解绑会释放 (provider, subject) 键以便未来重新绑定。", + "summary": "解绑外部身份", + "tags": "外部身份" + }, + "schema": { + "dc": "从当前会话用户移除一条外部身份链接。解绑仅作用于当前会话用户自己的链接;不属于当前用户的链接按不存在处理,不泄露其他账号的链接存在性。解绑会释放 (provider, subject) 键以便未来重新绑定。" + } + }, + "UnbindIdentityRes": {} + } + } + } + } + } +} diff --git a/linapro-oidc-core/manifest/i18n/zh-CN/error.json b/linapro-oidc-core/manifest/i18n/zh-CN/error.json new file mode 100644 index 00000000..7709a25b --- /dev/null +++ b/linapro-oidc-core/manifest/i18n/zh-CN/error.json @@ -0,0 +1,39 @@ +{ + "error": { + "plugin": { + "oidc": { + "core": { + "bind": { + "conflict": "该外部身份已绑定到其他账号" + }, + "identity": { + "invalid": "外部身份 provider 和 subject 不能为空", + "not": { + "found": "该外部身份未绑定到当前账号" + }, + "query": { + "failed": "查询外部身份链接失败" + }, + "write": { + "failed": "写入外部身份链接失败" + } + }, + "provision": { + "email": { + "conflict": "已存在使用相同邮箱的账号,请登录该账号后再绑定此外部身份" + }, + "failed": "为外部身份开通账号失败", + "not": { + "allowed": "本次登录未启用自动开户" + } + }, + "user": { + "capability": { + "unavailable": "宿主用户能力不可用" + } + } + } + } + } + } +} diff --git a/linapro-oidc-core/manifest/sql/001-linapro-oidc-core-identities.sql b/linapro-oidc-core/manifest/sql/001-linapro-oidc-core-identities.sql new file mode 100644 index 00000000..b7e3562c --- /dev/null +++ b/linapro-oidc-core/manifest/sql/001-linapro-oidc-core-identities.sql @@ -0,0 +1,49 @@ +-- ------------------------------------------------------------ +-- 001 linapro-oidc-core external identity linkage SQL file +-- 001 linapro-oidc-core 外部身份链接 SQL 文件 +-- Purpose: Stores plugin-owned linkages between a verified third-party identity +-- (provider + immutable subject) and a local user account. The (provider, +-- subject) partial unique index is the authoritative de-duplication anchor for +-- external login resolution and idempotent provisioning. Rows are soft-deleted +-- (deleted_at) for audit; the unique index only covers live rows so unbinding +-- frees the (provider, subject) key for a future relink. The table is +-- platform-scoped (no tenant_id): the identity binding is a property of the +-- user account, and tenant selection happens after login. +-- 用途:存储插件私有的「已验证第三方身份(provider + 不可变 subject)」与本地用户 +-- 账号之间的链接。(provider, subject) 部分唯一索引是外部登录解析与幂等开户的权威 +-- 去重锚点。行采用软删除(deleted_at)保留审计痕迹;唯一索引仅覆盖存活行,因此解绑 +-- 会释放 (provider, subject) 键以便未来重新绑定。表为平台级(无 tenant_id):身份 +-- 绑定属于用户账号本身,租户在登录后选择。 +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS user_external_identity ( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "user_id" INT NOT NULL, + "provider" VARCHAR(64) NOT NULL, + "subject" VARCHAR(191) NOT NULL, + "plugin_id" VARCHAR(128) NOT NULL DEFAULT '', + "email_snapshot" VARCHAR(191) NOT NULL DEFAULT '', + "created_at" TIMESTAMPTZ, + "updated_at" TIMESTAMPTZ, + "deleted_at" TIMESTAMPTZ +); + +COMMENT ON TABLE user_external_identity IS 'Verified external identity to local user linkage owned by linapro-oidc-core'; +COMMENT ON COLUMN user_external_identity."id" IS 'External identity linkage ID'; +COMMENT ON COLUMN user_external_identity."user_id" IS 'Linked local user ID'; +COMMENT ON COLUMN user_external_identity."provider" IS 'Stable external provider ID owned by the calling plugin, e.g. google, discord'; +COMMENT ON COLUMN user_external_identity."subject" IS 'Immutable provider-issued subject identifier, e.g. OIDC sub'; +COMMENT ON COLUMN user_external_identity."plugin_id" IS 'Calling plugin ID stamped by the host when the linkage was created'; +COMMENT ON COLUMN user_external_identity."email_snapshot" IS 'Email captured at link time for audit only, never used as a resolution key'; +COMMENT ON COLUMN user_external_identity."created_at" IS 'Creation time'; +COMMENT ON COLUMN user_external_identity."updated_at" IS 'Update time'; +COMMENT ON COLUMN user_external_identity."deleted_at" IS 'Soft delete time; live rows keep NULL'; + +-- The partial (provider, subject) unique index is the authoritative resolution +-- and de-duplication key. It only covers live rows so a soft-deleted (unbound) +-- linkage never blocks a future relink of the same external identity. +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_external_identity_provider_subject + ON user_external_identity ("provider", "subject") WHERE "deleted_at" IS NULL; +-- Supports listing/unbinding all external identities for one user. +CREATE INDEX IF NOT EXISTS idx_user_external_identity_user + ON user_external_identity ("user_id"); diff --git a/linapro-oidc-core/manifest/sql/uninstall/001-linapro-oidc-core-identities.sql b/linapro-oidc-core/manifest/sql/uninstall/001-linapro-oidc-core-identities.sql new file mode 100644 index 00000000..21663723 --- /dev/null +++ b/linapro-oidc-core/manifest/sql/uninstall/001-linapro-oidc-core-identities.sql @@ -0,0 +1,12 @@ +-- ------------------------------------------------------------ +-- 001 linapro-oidc-core external identity linkage uninstall SQL file +-- 001 linapro-oidc-core 外部身份链接卸载 SQL 文件 +-- Purpose: Drops the plugin-owned external identity linkage table when the user +-- confirms storage purge during uninstall. Provisioned local user accounts are +-- deliberately NOT cascaded: account lifecycle stays host-owned, and orphaned +-- least-privilege accounts remain manageable through host user management. +-- 用途:在卸载并确认清除数据时删除插件私有外部身份链接表。已开户的本地用户账号 +-- 刻意不级联删除:账号生命周期归宿主所有,遗留的最小权限账号仍可通过宿主用户管理治理。 +-- ------------------------------------------------------------ + +DROP TABLE IF EXISTS user_external_identity; diff --git a/linapro-oidc-core/plugin.yaml b/linapro-oidc-core/plugin.yaml new file mode 100644 index 00000000..435a89e6 --- /dev/null +++ b/linapro-oidc-core/plugin.yaml @@ -0,0 +1,62 @@ +# Plugin identifier, unique across the host and source plugin directories; use `kebab-case`. +# 插件唯一标识,必须在宿主和源码插件目录中保持唯一,并使用 `kebab-case` 命名风格。 +id: linapro-oidc-core + +# Plugin display name used in plugin management pages, developer docs, and presentation. +# 插件显示名称,用于插件管理页面、开发文档和展示。 +name: External Identity Core + +# 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. `builtin` marks this registered source plugin as a project +# component that host startup installs, enables, and safely upgrades; ordinary plugin-management +# write actions are rejected. +# 插件分发治理枚举。`builtin`表示该已注册源码插件是项目组件,由宿主启动自动安装、启用和安全升级; +# 常规插件管理写操作会被拒绝。 +distribution: builtin + +# 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: External identity core plugin that owns the (provider, subject) linkage storage and the resolve/provision/bind engine behind the host external-login seam. OAuth protocol plugins such as linapro-oidc-google and linapro-oidc-discord depend on it. + +# 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-core + +# 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: 简体中文 diff --git a/linapro-oidc-core/plugin_embed.go b/linapro-oidc-core/plugin_embed.go new file mode 100644 index 00000000..5a9e6927 --- /dev/null +++ b/linapro-oidc-core/plugin_embed.go @@ -0,0 +1,12 @@ +// Package pluginoidccore embeds the linapro-oidc-core plugin manifest and +// lifecycle SQL assets for source-plugin registration. The plugin ships no +// frontend pages: it is a capability-only plugin providing the external +// identity storage and provider engine. +package pluginoidccore + +import "embed" + +// EmbeddedFiles contains the plugin manifest and convention-based SQL and i18n assets. +// +//go:embed plugin.yaml manifest +var EmbeddedFiles embed.FS 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..afc668ec --- /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 tenant candidate resolution and token minting, and delegates `(provider, subject)` linkage resolution plus provisioning policy to `linapro-oidc-core`, which this plugin declares as a dependency in `plugin.yaml`. When `linapro-oidc-core` is not installed or enabled, external login is fail-closed. This 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 +- tenant resolution and token minting stay host-owned; linkage storage and provisioning policy stay in `linapro-oidc-core` +- 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..6b92ccaa --- /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 发放,并把`(provider, subject)`链接解析与开户策略委托给`linapro-oidc-core`(本插件已在`plugin.yaml`中声明对它的依赖)。`linapro-oidc-core`未安装或未启用时,外部登录 fail-closed。本插件只提交“已验证”的身份 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 发放保持宿主拥有;链接存储与开户策略归`linapro-oidc-core` +- 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..930c7e58 --- /dev/null +++ b/linapro-oidc-discord/backend/api/settings/v1/settings.go @@ -0,0 +1,31 @@ +// 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\"}"` + // 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_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..5dfdcd9f --- /dev/null +++ b/linapro-oidc-discord/backend/api/settings/v1/settings_save.go @@ -0,0 +1,28 @@ +// 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\"}"` + // 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. +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..e9eda617 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/controller/settings/settings_v1_save.go @@ -0,0 +1,47 @@ +// 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, + AllowAutoProvision: req.AllowAutoProvision, + }) + 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, + AllowAutoProvision: projection.AllowAutoProvision, + } +} 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..c2e8d2b2 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/oauth/oauth_callback.go @@ -0,0 +1,103 @@ +// 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" +) + +// 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; +// 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, + AllowAutoProvision: s.resolveAllowAutoProvision(ctx), + }) + 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..065e4bd1 --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings.go @@ -0,0 +1,143 @@ +// 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" + // 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 +// 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 + // 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 +// 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 + // AllowAutoProvision reports whether auto-provisioning is enabled. + AllowAutoProvision bool +} + +// 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 + // AllowAutoProvision replaces the stored auto-provision enablement flag. + AllowAutoProvision bool +} + +// 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..6631b52f --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_read.go @@ -0,0 +1,102 @@ +// 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, + ConfigKeyAllowAutoProvision, + } +} + +// 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 + } + if info := result.Items[ConfigKeyAllowAutoProvision]; info != nil { + snapshot.AllowAutoProvision = info.Value == enabledFlagValue + } + } + 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 + projection.AllowAutoProvision = snapshot.AllowAutoProvision + 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..d868491f --- /dev/null +++ b/linapro-oidc-discord/backend/internal/service/settings/settings_save.go @@ -0,0 +1,126 @@ +// 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 + } + 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 + } + } + 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, + AllowAutoProvision: in.AllowAutoProvision, + }), 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..638b5326 --- /dev/null +++ b/linapro-oidc-discord/frontend/pages/settings.vue @@ -0,0 +1,338 @@ + + + + + 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..713a5d72 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/en-US/plugin.json @@ -0,0 +1,46 @@ +{ + "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", + "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 new file mode 100644 index 00000000..5a3e6939 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/apidoc/plugin-api-main.json @@ -0,0 +1,97 @@ +{ + "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 对象;空字符串清除所有规则" + }, + "allowAutoProvision": { + "dc": "允许宿主为未绑定的已验证 Discord 身份自动创建最小权限平台账号" + } + } + }, + "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 对象" + }, + "allowAutoProvision": { + "dc": "当前是否允许自动注册未绑定的已验证 Discord 身份" + } + } + } + } + } + } + } + } +} 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..29665122 --- /dev/null +++ b/linapro-oidc-discord/manifest/i18n/zh-CN/plugin.json @@ -0,0 +1,46 @@ +{ + "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": "删除", + "autoProvisionLabel": "自动注册新用户", + "autoProvisionHint": "开启后,已验证但未绑定本地账号的 Discord 身份会自动创建最小权限的平台账号。若邮箱已属于现有账号,登录会被拒绝,需先登录该账号完成绑定。" + } + } + } +} diff --git a/linapro-oidc-discord/plugin.yaml b/linapro-oidc-discord/plugin.yaml new file mode 100644 index 00000000..768b0c84 --- /dev/null +++ b/linapro-oidc-discord/plugin.yaml @@ -0,0 +1,130 @@ +# 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 dependency declaration. `plugins` lists plugin-to-plugin dependencies. +# 插件依赖声明。`plugins` 声明插件间依赖。 +dependencies: + # Other plugins required by this plugin. linapro-oidc-core owns the external + # identity linkage storage and the resolve/provision engine behind the host + # external-login seam; without it enabled, external login is fail-closed. + # 当前插件依赖的其他插件。linapro-oidc-core 拥有外部身份链接存储和宿主外部登录 + # seam 背后的解析/开户引擎;未启用时外部登录 fail-closed。 + plugins: + # Dependency plugin ID. + # 依赖插件 ID。 + - id: linapro-oidc-core + # Dependency plugin semantic-version range. + # 依赖插件语义化版本范围。 + version: ">=0.1.0" + +# 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 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..272c81a9 --- /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 tenant candidate resolution and token minting, and delegates `(provider, subject)` linkage resolution plus provisioning policy to `linapro-oidc-core`, which this plugin declares as a dependency in `plugin.yaml`. When `linapro-oidc-core` is not installed or enabled, external login is fail-closed. This 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 +- tenant resolution and token minting stay host-owned; linkage storage and provisioning policy stay in `linapro-oidc-core` +- 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..d0373d94 --- /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 发放,并把`(provider, subject)`链接解析与开户策略委托给`linapro-oidc-core`(本插件已在`plugin.yaml`中声明对它的依赖)。`linapro-oidc-core`未安装或未启用时,外部登录 fail-closed。本插件只提交“已验证”的身份 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 发放保持宿主拥有;链接存储与开户策略归`linapro-oidc-core` +- 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..8baff394 --- /dev/null +++ b/linapro-oidc-google/backend/api/settings/v1/settings.go @@ -0,0 +1,33 @@ +// 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\"}"` + // 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_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..dc2227cf --- /dev/null +++ b/linapro-oidc-google/backend/api/settings/v1/settings_save.go @@ -0,0 +1,30 @@ +// 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\"}"` + // 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. +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_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/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..cc9e004c --- /dev/null +++ b/linapro-oidc-google/backend/internal/controller/settings/settings_v1_save.go @@ -0,0 +1,49 @@ +// 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, + AllowAutoProvision: req.AllowAutoProvision, + EnableOneTap: req.EnableOneTap, + }) + 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, + 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 new file mode 100644 index 00000000..9b89c0b3 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth.go @@ -0,0 +1,196 @@ +// 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 + // 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. +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. + 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 +// 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. +// 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, + } +} + +// 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..5d17b67e --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_callback.go @@ -0,0 +1,103 @@ +// 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" +) + +// 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; +// 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, + 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: 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..cae8f8ef --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/oauth/oauth_code.go @@ -0,0 +1,74 @@ +// 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, + ) + // 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_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_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/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..aef671e8 --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings.go @@ -0,0 +1,153 @@ +// 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" + // 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 +// 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 + // 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 +// 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 + // 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 +// 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 + // 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 +// 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..196b830c --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings_read.go @@ -0,0 +1,107 @@ +// 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, + ConfigKeyAllowAutoProvision, + ConfigKeyEnableOneTap, + } +} + +// 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 + } + 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 +} + +// 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 + projection.AllowAutoProvision = snapshot.AllowAutoProvision + projection.EnableOneTap = snapshot.EnableOneTap + 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..76a8892c --- /dev/null +++ b/linapro-oidc-google/backend/internal/service/settings/settings_save.go @@ -0,0 +1,134 @@ +// 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 + } + 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 + } + } + 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, + AllowAutoProvision: in.AllowAutoProvision, + EnableOneTap: in.EnableOneTap, + }), 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..5892365f --- /dev/null +++ b/linapro-oidc-google/backend/plugin.go @@ -0,0 +1,123 @@ +// 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(), + oauthsvc.NewJWKSIDTokenVerifier(configResolver), + ) + 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) + group.POST("/onetap", loginController.OneTap) + }) + 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..cda0f084 --- /dev/null +++ b/linapro-oidc-google/frontend/pages/settings.vue @@ -0,0 +1,412 @@ + + + + + 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..0849d0d4 --- /dev/null +++ b/linapro-oidc-google/go.mod @@ -0,0 +1,41 @@ +module lina-plugin-linapro-oidc-google + +go 1.25.0 + +require ( + github.com/gogf/gf/v2 v2.10.1 + github.com/golang-jwt/jwt/v5 v5.3.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..eb2be900 --- /dev/null +++ b/linapro-oidc-google/go.sum @@ -0,0 +1,87 @@ +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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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..6cc33a90 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/en-US/error.json @@ -0,0 +1,50 @@ +{ + "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" + } + }, + "one": { + "tap": { + "disabled": "Google One Tap login is disabled", + "csrf": { + "mismatch": "One Tap CSRF validation failed" + } + } + }, + "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..d54b69cb --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/en-US/plugin.json @@ -0,0 +1,52 @@ +{ + "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", + "autoProvisionLabel": "Auto-provision new users", + "autoProvisionHint": "When enabled, a verified Google 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.", + "oneTapLabel": "Enable One Tap embed", + "oneTapHint": "Accept Google One Tap ID Token sign-ins posted by the embeddable snippet below. Third-party pages hosting the snippet must be registered as Authorized JavaScript origins in Google Cloud Console, and the onetap URL as an Authorized redirect URI.", + "oneTapEmbedTitle": "Embeddable Snippet", + "oneTapStateKeyPlaceholder": "SSO state key (optional)", + "copyEmbed": "Copy Snippet", + "oneTapEmbedHint": "Paste this snippet into any page. After a successful sign-in the outcome follows the SSO delivery rules: a matching state key redirects to the configured receiver URL with the tokens; otherwise the browser lands on the workspace login handoff." + } + } + } +} 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..581620c7 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/zh-CN/apidoc/plugin-api-main.json @@ -0,0 +1,103 @@ +{ + "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 对象;空字符串清除所有规则" + }, + "allowAutoProvision": { + "dc": "允许宿主为未绑定的已验证 Google 身份自动创建最小权限平台账号" + }, + "enableOneTap": { + "dc": "启用可嵌入的 Google One Tap 登录端点" + } + } + }, + "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 对象" + }, + "allowAutoProvision": { + "dc": "当前是否允许自动注册未绑定的已验证 Google 身份" + }, + "enableOneTap": { + "dc": "当前是否启用可嵌入的 One Tap 登录端点" + } + } + } + } + } + } + } + } +} 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..b10508ce --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/zh-CN/error.json @@ -0,0 +1,50 @@ +{ + "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 账号邮箱未通过验证" + } + }, + "one": { + "tap": { + "disabled": "Google One Tap 登录未启用", + "csrf": { + "mismatch": "One Tap CSRF 校验失败" + } + } + }, + "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..310d9141 --- /dev/null +++ b/linapro-oidc-google/manifest/i18n/zh-CN/plugin.json @@ -0,0 +1,52 @@ +{ + "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": "删除", + "autoProvisionLabel": "自动注册新用户", + "autoProvisionHint": "开启后,已验证但未绑定本地账号的 Google 身份会自动创建最小权限的平台账号。若邮箱已属于现有账号,登录会被拒绝,需先登录该账号完成绑定。", + "oneTapLabel": "启用 One Tap 嵌入", + "oneTapHint": "接受下方嵌入片段提交的 Google One Tap ID Token 登录。承载片段的第三方页面域名需在 Google Cloud Console 注册为「已获授权的 JavaScript 来源」,onetap 地址需注册为「已获授权的重定向 URI」。", + "oneTapEmbedTitle": "嵌入代码", + "oneTapStateKeyPlaceholder": "SSO state key(可选)", + "copyEmbed": "复制代码", + "oneTapEmbedHint": "把这段代码粘贴到任意页面即可。登录成功后按 SSO 投递规则处理:state key 命中规则时携带令牌跳转到配置的接收地址,否则进入工作台登录交接页。" + } + } + } +} diff --git a/linapro-oidc-google/plugin.yaml b/linapro-oidc-google/plugin.yaml new file mode 100644 index 00000000..08ef6e0c --- /dev/null +++ b/linapro-oidc-google/plugin.yaml @@ -0,0 +1,130 @@ +# 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 dependency declaration. `plugins` lists plugin-to-plugin dependencies. +# 插件依赖声明。`plugins` 声明插件间依赖。 +dependencies: + # Other plugins required by this plugin. linapro-oidc-core owns the external + # identity linkage storage and the resolve/provision engine behind the host + # external-login seam; without it enabled, external login is fail-closed. + # 当前插件依赖的其他插件。linapro-oidc-core 拥有外部身份链接存储和宿主外部登录 + # seam 背后的解析/开户引擎;未启用时外部登录 fail-closed。 + plugins: + # Dependency plugin ID. + # 依赖插件 ID。 + - id: linapro-oidc-core + # Dependency plugin semantic-version range. + # 依赖插件语义化版本范围。 + version: ">=0.1.0" + +# 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 diff --git a/linapro-org-core/backend/plugin_test.go b/linapro-org-core/backend/plugin_test.go index 7b98aeab..b81abc1a 100644 --- a/linapro-org-core/backend/plugin_test.go +++ b/linapro-org-core/backend/plugin_test.go @@ -102,6 +102,10 @@ func (fakeUsers) Create(context.Context, usercap.CreateInput) (usercap.UserID, e return "", nil } +func (fakeUsers) ProvisionExternal(context.Context, usercap.ProvisionExternalInput) (usercap.UserID, error) { + return "", nil +} + // Update is unused by construction-only tests. func (fakeUsers) Update(context.Context, usercap.UpdateInput) error { return nil diff --git a/linapro-tenant-core/backend/internal/service/impersonate/impersonate_test.go b/linapro-tenant-core/backend/internal/service/impersonate/impersonate_test.go index 45f97b38..02084406 100644 --- a/linapro-tenant-core/backend/internal/service/impersonate/impersonate_test.go +++ b/linapro-tenant-core/backend/internal/service/impersonate/impersonate_test.go @@ -268,6 +268,10 @@ func (s fakeImpersonationUsers) Create(context.Context, usercap.CreateInput) (us return "", nil } +func (s fakeImpersonationUsers) ProvisionExternal(context.Context, usercap.ProvisionExternalInput) (usercap.UserID, error) { + return "", nil +} + // Update is unused by impersonation tests. func (s fakeImpersonationUsers) Update(context.Context, usercap.UpdateInput) error { return nil diff --git a/linapro-tenant-core/backend/internal/service/membership/membership_test.go b/linapro-tenant-core/backend/internal/service/membership/membership_test.go index 8293ede7..0353722e 100644 --- a/linapro-tenant-core/backend/internal/service/membership/membership_test.go +++ b/linapro-tenant-core/backend/internal/service/membership/membership_test.go @@ -132,6 +132,10 @@ func (membershipTestUsers) Create(context.Context, usercap.CreateInput) (usercap return "", nil } +func (membershipTestUsers) ProvisionExternal(context.Context, usercap.ProvisionExternalInput) (usercap.UserID, error) { + return "", nil +} + // Update is unused by membership tests. func (membershipTestUsers) Update(context.Context, usercap.UpdateInput) error { return nil diff --git a/linapro-tenant-core/backend/internal/service/provider/provider_provisioning_test.go b/linapro-tenant-core/backend/internal/service/provider/provider_provisioning_test.go index 5800e3ea..f31ff06d 100644 --- a/linapro-tenant-core/backend/internal/service/provider/provider_provisioning_test.go +++ b/linapro-tenant-core/backend/internal/service/provider/provider_provisioning_test.go @@ -111,6 +111,10 @@ func (providerProvisioningUsers) Create(context.Context, usercap.CreateInput) (u return "", nil } +func (providerProvisioningUsers) ProvisionExternal(context.Context, usercap.ProvisionExternalInput) (usercap.UserID, error) { + return "", nil +} + // Update is unused by startup provisioning tests. func (providerProvisioningUsers) Update(context.Context, usercap.UpdateInput) error { return nil diff --git a/linapro-tenant-core/backend/plugin_provider_test.go b/linapro-tenant-core/backend/plugin_provider_test.go index c0620558..7cc8d460 100644 --- a/linapro-tenant-core/backend/plugin_provider_test.go +++ b/linapro-tenant-core/backend/plugin_provider_test.go @@ -94,6 +94,10 @@ func (fakeTenantProviderUsers) Create(context.Context, usercap.CreateInput) (use return "", nil } +func (fakeTenantProviderUsers) ProvisionExternal(context.Context, usercap.ProvisionExternalInput) (usercap.UserID, error) { + return "", nil +} + // Update is unused by provider construction tests. func (fakeTenantProviderUsers) Update(context.Context, usercap.UpdateInput) error { return nil