mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
merge upstream main into fix/bug-cleanup-main
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/Wei-Shaw/sub2api/ent/group"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
// Group is the model entity for the Group schema.
|
||||
@@ -76,6 +77,8 @@ type Group struct {
|
||||
RequirePrivacySet bool `json:"require_privacy_set,omitempty"`
|
||||
// 默认映射模型 ID,当账号级映射找不到时使用此值
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
// Edges holds the relations/edges for other nodes in the graph.
|
||||
// The values are being populated by the GroupQuery when eager-loading is set.
|
||||
Edges GroupEdges `json:"edges"`
|
||||
@@ -182,7 +185,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes:
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||
values[i] = new(sql.NullBool)
|
||||
@@ -403,6 +406,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.DefaultMappedModel = value.String
|
||||
}
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field messages_dispatch_model_config", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.MessagesDispatchModelConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
@@ -585,6 +596,9 @@ func (_m *Group) String() string {
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("default_mapped_model=")
|
||||
builder.WriteString(_m.DefaultMappedModel)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("messages_dispatch_model_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,6 +74,8 @@ const (
|
||||
FieldRequirePrivacySet = "require_privacy_set"
|
||||
// FieldDefaultMappedModel holds the string denoting the default_mapped_model field in the database.
|
||||
FieldDefaultMappedModel = "default_mapped_model"
|
||||
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
|
||||
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
||||
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
|
||||
EdgeAPIKeys = "api_keys"
|
||||
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
|
||||
@@ -177,6 +180,7 @@ var Columns = []string{
|
||||
FieldRequireOauthOnly,
|
||||
FieldRequirePrivacySet,
|
||||
FieldDefaultMappedModel,
|
||||
FieldMessagesDispatchModelConfig,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -252,6 +256,8 @@ var (
|
||||
DefaultDefaultMappedModel string
|
||||
// DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
DefaultMappedModelValidator func(string) error
|
||||
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
|
||||
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the Group queries.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
// GroupCreate is the builder for creating a Group entity.
|
||||
@@ -410,6 +411,20 @@ func (_c *GroupCreate) SetNillableDefaultMappedModel(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (_c *GroupCreate) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupCreate {
|
||||
_c.mutation.SetMessagesDispatchModelConfig(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMessagesDispatchModelConfig) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetMessagesDispatchModelConfig(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate {
|
||||
_c.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -611,6 +626,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultDefaultMappedModel
|
||||
_c.mutation.SetDefaultMappedModel(v)
|
||||
}
|
||||
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
|
||||
v := group.DefaultMessagesDispatchModelConfig
|
||||
_c.mutation.SetMessagesDispatchModelConfig(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -695,6 +714,9 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
|
||||
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -838,6 +860,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value)
|
||||
_node.DefaultMappedModel = value
|
||||
}
|
||||
if value, ok := _c.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
_node.MessagesDispatchModelConfig = value
|
||||
}
|
||||
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -1462,6 +1488,18 @@ func (u *GroupUpsert) UpdateDefaultMappedModel() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (u *GroupUpsert) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpsert {
|
||||
u.Set(group.FieldMessagesDispatchModelConfig, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldMessagesDispatchModelConfig)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
||||
// Using this option is equivalent to using:
|
||||
//
|
||||
@@ -2053,6 +2091,20 @@ func (u *GroupUpsertOne) UpdateDefaultMappedModel() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (u *GroupUpsertOne) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMessagesDispatchModelConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMessagesDispatchModelConfig()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *GroupUpsertOne) Exec(ctx context.Context) error {
|
||||
if len(u.create.conflict) == 0 {
|
||||
@@ -2810,6 +2862,20 @@ func (u *GroupUpsertBulk) UpdateDefaultMappedModel() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (u *GroupUpsertBulk) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMessagesDispatchModelConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMessagesDispatchModelConfig()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *GroupUpsertBulk) Exec(ctx context.Context) error {
|
||||
if u.create.err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
// GroupUpdate is the builder for updating Group entities.
|
||||
@@ -552,6 +553,20 @@ func (_u *GroupUpdate) SetNillableDefaultMappedModel(v *string) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (_u *GroupUpdate) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpdate {
|
||||
_u.mutation.SetMessagesDispatchModelConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMessagesDispatchModelConfig) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetMessagesDispatchModelConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -1012,6 +1027,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.DefaultMappedModel(); ok {
|
||||
_spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -1843,6 +1861,20 @@ func (_u *GroupUpdateOne) SetNillableDefaultMappedModel(v *string) *GroupUpdateO
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (_u *GroupUpdateOne) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetMessagesDispatchModelConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMessagesDispatchModelConfig) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetMessagesDispatchModelConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -2333,6 +2365,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.DefaultMappedModel(); ok {
|
||||
_spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
|
||||
@@ -407,6 +407,7 @@ var (
|
||||
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
||||
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
|
||||
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
}
|
||||
// GroupsTable holds the schema information for the "groups" table.
|
||||
GroupsTable = &schema.Table{
|
||||
|
||||
@@ -8246,6 +8246,7 @@ type GroupMutation struct {
|
||||
require_oauth_only *bool
|
||||
require_privacy_set *bool
|
||||
default_mapped_model *string
|
||||
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
||||
clearedFields map[string]struct{}
|
||||
api_keys map[int64]struct{}
|
||||
removedapi_keys map[int64]struct{}
|
||||
@@ -9798,6 +9799,42 @@ func (m *GroupMutation) ResetDefaultMappedModel() {
|
||||
m.default_mapped_model = nil
|
||||
}
|
||||
|
||||
// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field.
|
||||
func (m *GroupMutation) SetMessagesDispatchModelConfig(damdmc domain.OpenAIMessagesDispatchModelConfig) {
|
||||
m.messages_dispatch_model_config = &damdmc
|
||||
}
|
||||
|
||||
// MessagesDispatchModelConfig returns the value of the "messages_dispatch_model_config" field in the mutation.
|
||||
func (m *GroupMutation) MessagesDispatchModelConfig() (r domain.OpenAIMessagesDispatchModelConfig, exists bool) {
|
||||
v := m.messages_dispatch_model_config
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldMessagesDispatchModelConfig returns the old "messages_dispatch_model_config" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldMessagesDispatchModelConfig(ctx context.Context) (v domain.OpenAIMessagesDispatchModelConfig, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldMessagesDispatchModelConfig is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldMessagesDispatchModelConfig requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldMessagesDispatchModelConfig: %w", err)
|
||||
}
|
||||
return oldValue.MessagesDispatchModelConfig, nil
|
||||
}
|
||||
|
||||
// ResetMessagesDispatchModelConfig resets all changes to the "messages_dispatch_model_config" field.
|
||||
func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
|
||||
m.messages_dispatch_model_config = nil
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
|
||||
func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) {
|
||||
if m.api_keys == nil {
|
||||
@@ -10156,7 +10193,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 29)
|
||||
fields := make([]string, 0, 30)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -10244,6 +10281,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.default_mapped_model != nil {
|
||||
fields = append(fields, group.FieldDefaultMappedModel)
|
||||
}
|
||||
if m.messages_dispatch_model_config != nil {
|
||||
fields = append(fields, group.FieldMessagesDispatchModelConfig)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -10310,6 +10350,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.RequirePrivacySet()
|
||||
case group.FieldDefaultMappedModel:
|
||||
return m.DefaultMappedModel()
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.MessagesDispatchModelConfig()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -10377,6 +10419,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldRequirePrivacySet(ctx)
|
||||
case group.FieldDefaultMappedModel:
|
||||
return m.OldDefaultMappedModel(ctx)
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.OldMessagesDispatchModelConfig(ctx)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -10589,6 +10633,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetDefaultMappedModel(v)
|
||||
return nil
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
v, ok := value.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetMessagesDispatchModelConfig(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -10929,6 +10980,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldDefaultMappedModel:
|
||||
m.ResetDefaultMappedModel()
|
||||
return nil
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
m.ResetMessagesDispatchModelConfig()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/userattributedefinition"
|
||||
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
// The init function reads all schema descriptors with runtime code
|
||||
@@ -468,6 +469,10 @@ func init() {
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[26].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields()
|
||||
_ = idempotencyrecordMixinFields0
|
||||
|
||||
@@ -141,6 +141,10 @@ func (Group) Fields() []ent.Field {
|
||||
MaxLen(100).
|
||||
Default("").
|
||||
Comment("默认映射模型 ID,当账号级映射找不到时使用此值"),
|
||||
field.JSON("messages_dispatch_model_config", domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ type Config struct {
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Totp TotpConfig `mapstructure:"totp"`
|
||||
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||||
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
||||
Default DefaultConfig `mapstructure:"default"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Pricing PricingConfig `mapstructure:"pricing"`
|
||||
@@ -184,6 +185,34 @@ type LinuxDoConnectConfig struct {
|
||||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
||||
}
|
||||
|
||||
type OIDCConnectConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ProviderName string `mapstructure:"provider_name"` // 显示名: "Keycloak" 等
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
IssuerURL string `mapstructure:"issuer_url"`
|
||||
DiscoveryURL string `mapstructure:"discovery_url"`
|
||||
AuthorizeURL string `mapstructure:"authorize_url"`
|
||||
TokenURL string `mapstructure:"token_url"`
|
||||
UserInfoURL string `mapstructure:"userinfo_url"`
|
||||
JWKSURL string `mapstructure:"jwks_url"`
|
||||
Scopes string `mapstructure:"scopes"` // 默认 "openid email profile"
|
||||
RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记)
|
||||
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/oidc/callback)
|
||||
TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none
|
||||
UsePKCE bool `mapstructure:"use_pkce"`
|
||||
ValidateIDToken bool `mapstructure:"validate_id_token"`
|
||||
AllowedSigningAlgs string `mapstructure:"allowed_signing_algs"` // 默认 "RS256,ES256,PS256"
|
||||
ClockSkewSeconds int `mapstructure:"clock_skew_seconds"` // 默认 120
|
||||
RequireEmailVerified bool `mapstructure:"require_email_verified"` // 默认 false
|
||||
|
||||
// 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。
|
||||
// 为空时,服务端会尝试一组常见字段名。
|
||||
UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
|
||||
UserInfoIDPath string `mapstructure:"userinfo_id_path"`
|
||||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
||||
}
|
||||
|
||||
// TokenRefreshConfig OAuth token自动刷新配置
|
||||
type TokenRefreshConfig struct {
|
||||
// 是否启用自动刷新
|
||||
@@ -318,6 +347,12 @@ type GatewayConfig struct {
|
||||
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
|
||||
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
|
||||
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
|
||||
// ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。
|
||||
// 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。
|
||||
ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"`
|
||||
// ForcedCodexInstructionsTemplate: 启动时从模板文件读取并缓存的模板内容。
|
||||
// 该字段不直接参与配置反序列化,仅用于请求热路径避免重复读盘。
|
||||
ForcedCodexInstructionsTemplate string `mapstructure:"-"`
|
||||
// OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头
|
||||
// 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。
|
||||
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
|
||||
@@ -972,6 +1007,23 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
|
||||
cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
|
||||
cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath)
|
||||
cfg.OIDC.ProviderName = strings.TrimSpace(cfg.OIDC.ProviderName)
|
||||
cfg.OIDC.ClientID = strings.TrimSpace(cfg.OIDC.ClientID)
|
||||
cfg.OIDC.ClientSecret = strings.TrimSpace(cfg.OIDC.ClientSecret)
|
||||
cfg.OIDC.IssuerURL = strings.TrimSpace(cfg.OIDC.IssuerURL)
|
||||
cfg.OIDC.DiscoveryURL = strings.TrimSpace(cfg.OIDC.DiscoveryURL)
|
||||
cfg.OIDC.AuthorizeURL = strings.TrimSpace(cfg.OIDC.AuthorizeURL)
|
||||
cfg.OIDC.TokenURL = strings.TrimSpace(cfg.OIDC.TokenURL)
|
||||
cfg.OIDC.UserInfoURL = strings.TrimSpace(cfg.OIDC.UserInfoURL)
|
||||
cfg.OIDC.JWKSURL = strings.TrimSpace(cfg.OIDC.JWKSURL)
|
||||
cfg.OIDC.Scopes = strings.TrimSpace(cfg.OIDC.Scopes)
|
||||
cfg.OIDC.RedirectURL = strings.TrimSpace(cfg.OIDC.RedirectURL)
|
||||
cfg.OIDC.FrontendRedirectURL = strings.TrimSpace(cfg.OIDC.FrontendRedirectURL)
|
||||
cfg.OIDC.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.OIDC.TokenAuthMethod))
|
||||
cfg.OIDC.AllowedSigningAlgs = strings.TrimSpace(cfg.OIDC.AllowedSigningAlgs)
|
||||
cfg.OIDC.UserInfoEmailPath = strings.TrimSpace(cfg.OIDC.UserInfoEmailPath)
|
||||
cfg.OIDC.UserInfoIDPath = strings.TrimSpace(cfg.OIDC.UserInfoIDPath)
|
||||
cfg.OIDC.UserInfoUsernamePath = strings.TrimSpace(cfg.OIDC.UserInfoUsernamePath)
|
||||
cfg.Dashboard.KeyPrefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix)
|
||||
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
||||
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
||||
@@ -983,6 +1035,14 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
cfg.Log.Environment = strings.TrimSpace(cfg.Log.Environment)
|
||||
cfg.Log.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel))
|
||||
cfg.Log.Output.FilePath = strings.TrimSpace(cfg.Log.Output.FilePath)
|
||||
cfg.Gateway.ForcedCodexInstructionsTemplateFile = strings.TrimSpace(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
|
||||
if cfg.Gateway.ForcedCodexInstructionsTemplateFile != "" {
|
||||
content, err := os.ReadFile(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read forced codex instructions template %q: %w", cfg.Gateway.ForcedCodexInstructionsTemplateFile, err)
|
||||
}
|
||||
cfg.Gateway.ForcedCodexInstructionsTemplate = string(content)
|
||||
}
|
||||
|
||||
// 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。
|
||||
// 新键未配置(<=0)时回退旧键;新键优先。
|
||||
@@ -1142,6 +1202,30 @@ func setDefaults() {
|
||||
viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
|
||||
viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
|
||||
|
||||
// Generic OIDC OAuth 登录
|
||||
viper.SetDefault("oidc_connect.enabled", false)
|
||||
viper.SetDefault("oidc_connect.provider_name", "OIDC")
|
||||
viper.SetDefault("oidc_connect.client_id", "")
|
||||
viper.SetDefault("oidc_connect.client_secret", "")
|
||||
viper.SetDefault("oidc_connect.issuer_url", "")
|
||||
viper.SetDefault("oidc_connect.discovery_url", "")
|
||||
viper.SetDefault("oidc_connect.authorize_url", "")
|
||||
viper.SetDefault("oidc_connect.token_url", "")
|
||||
viper.SetDefault("oidc_connect.userinfo_url", "")
|
||||
viper.SetDefault("oidc_connect.jwks_url", "")
|
||||
viper.SetDefault("oidc_connect.scopes", "openid email profile")
|
||||
viper.SetDefault("oidc_connect.redirect_url", "")
|
||||
viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback")
|
||||
viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post")
|
||||
viper.SetDefault("oidc_connect.use_pkce", false)
|
||||
viper.SetDefault("oidc_connect.validate_id_token", true)
|
||||
viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256")
|
||||
viper.SetDefault("oidc_connect.clock_skew_seconds", 120)
|
||||
viper.SetDefault("oidc_connect.require_email_verified", false)
|
||||
viper.SetDefault("oidc_connect.userinfo_email_path", "")
|
||||
viper.SetDefault("oidc_connect.userinfo_id_path", "")
|
||||
viper.SetDefault("oidc_connect.userinfo_username_path", "")
|
||||
|
||||
// Database
|
||||
viper.SetDefault("database.host", "localhost")
|
||||
viper.SetDefault("database.port", 5432)
|
||||
@@ -1578,6 +1662,87 @@ func (c *Config) Validate() error {
|
||||
warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL)
|
||||
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
|
||||
}
|
||||
if c.OIDC.Enabled {
|
||||
if strings.TrimSpace(c.OIDC.ClientID) == "" {
|
||||
return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true")
|
||||
}
|
||||
if strings.TrimSpace(c.OIDC.IssuerURL) == "" {
|
||||
return fmt.Errorf("oidc_connect.issuer_url is required when oidc_connect.enabled=true")
|
||||
}
|
||||
if strings.TrimSpace(c.OIDC.RedirectURL) == "" {
|
||||
return fmt.Errorf("oidc_connect.redirect_url is required when oidc_connect.enabled=true")
|
||||
}
|
||||
if strings.TrimSpace(c.OIDC.FrontendRedirectURL) == "" {
|
||||
return fmt.Errorf("oidc_connect.frontend_redirect_url is required when oidc_connect.enabled=true")
|
||||
}
|
||||
if !scopeContainsOpenID(c.OIDC.Scopes) {
|
||||
return fmt.Errorf("oidc_connect.scopes must contain openid")
|
||||
}
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(c.OIDC.TokenAuthMethod))
|
||||
switch method {
|
||||
case "", "client_secret_post", "client_secret_basic", "none":
|
||||
default:
|
||||
return fmt.Errorf("oidc_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
|
||||
}
|
||||
if method == "none" && !c.OIDC.UsePKCE {
|
||||
return fmt.Errorf("oidc_connect.use_pkce must be true when oidc_connect.token_auth_method=none")
|
||||
}
|
||||
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") &&
|
||||
strings.TrimSpace(c.OIDC.ClientSecret) == "" {
|
||||
return fmt.Errorf("oidc_connect.client_secret is required when oidc_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic")
|
||||
}
|
||||
if c.OIDC.ClockSkewSeconds < 0 || c.OIDC.ClockSkewSeconds > 600 {
|
||||
return fmt.Errorf("oidc_connect.clock_skew_seconds must be between 0 and 600")
|
||||
}
|
||||
if c.OIDC.ValidateIDToken && strings.TrimSpace(c.OIDC.AllowedSigningAlgs) == "" {
|
||||
return fmt.Errorf("oidc_connect.allowed_signing_algs is required when oidc_connect.validate_id_token=true")
|
||||
}
|
||||
|
||||
if err := ValidateAbsoluteHTTPURL(c.OIDC.IssuerURL); err != nil {
|
||||
return fmt.Errorf("oidc_connect.issuer_url invalid: %w", err)
|
||||
}
|
||||
if v := strings.TrimSpace(c.OIDC.DiscoveryURL); v != "" {
|
||||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return fmt.Errorf("oidc_connect.discovery_url invalid: %w", err)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.OIDC.AuthorizeURL); v != "" {
|
||||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return fmt.Errorf("oidc_connect.authorize_url invalid: %w", err)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.OIDC.TokenURL); v != "" {
|
||||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return fmt.Errorf("oidc_connect.token_url invalid: %w", err)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.OIDC.UserInfoURL); v != "" {
|
||||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return fmt.Errorf("oidc_connect.userinfo_url invalid: %w", err)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.OIDC.JWKSURL); v != "" {
|
||||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return fmt.Errorf("oidc_connect.jwks_url invalid: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ValidateAbsoluteHTTPURL(c.OIDC.RedirectURL); err != nil {
|
||||
return fmt.Errorf("oidc_connect.redirect_url invalid: %w", err)
|
||||
}
|
||||
if err := ValidateFrontendRedirectURL(c.OIDC.FrontendRedirectURL); err != nil {
|
||||
return fmt.Errorf("oidc_connect.frontend_redirect_url invalid: %w", err)
|
||||
}
|
||||
|
||||
warnIfInsecureURL("oidc_connect.issuer_url", c.OIDC.IssuerURL)
|
||||
warnIfInsecureURL("oidc_connect.discovery_url", c.OIDC.DiscoveryURL)
|
||||
warnIfInsecureURL("oidc_connect.authorize_url", c.OIDC.AuthorizeURL)
|
||||
warnIfInsecureURL("oidc_connect.token_url", c.OIDC.TokenURL)
|
||||
warnIfInsecureURL("oidc_connect.userinfo_url", c.OIDC.UserInfoURL)
|
||||
warnIfInsecureURL("oidc_connect.jwks_url", c.OIDC.JWKSURL)
|
||||
warnIfInsecureURL("oidc_connect.redirect_url", c.OIDC.RedirectURL)
|
||||
warnIfInsecureURL("oidc_connect.frontend_redirect_url", c.OIDC.FrontendRedirectURL)
|
||||
}
|
||||
if c.Billing.CircuitBreaker.Enabled {
|
||||
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
|
||||
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
|
||||
@@ -2196,6 +2361,15 @@ func ValidateFrontendRedirectURL(raw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func scopeContainsOpenID(scopes string) bool {
|
||||
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
|
||||
if scope == "openid" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isHTTPScheme 检查是否为 HTTP 或 HTTPS 协议
|
||||
func isHTTPScheme(scheme string) bool {
|
||||
return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -223,6 +225,23 @@ func TestLoadSchedulingConfigFromEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForcedCodexInstructionsTemplate(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
templatePath := filepath.Join(tempDir, "codex-instructions.md.tmpl")
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644))
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+templatePath+"\"\n"), 0o644))
|
||||
t.Setenv("DATA_DIR", tempDir)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, templatePath, cfg.Gateway.ForcedCodexInstructionsTemplateFile)
|
||||
require.Equal(t, "server-prefix\n\n{{ .ExistingInstructions }}", cfg.Gateway.ForcedCodexInstructionsTemplate)
|
||||
}
|
||||
|
||||
func TestLoadDefaultSecurityToggles(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -351,6 +370,60 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOIDCScopesMustContainOpenID(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
cfg.OIDC.Enabled = true
|
||||
cfg.OIDC.ClientID = "oidc-client"
|
||||
cfg.OIDC.ClientSecret = "oidc-secret"
|
||||
cfg.OIDC.IssuerURL = "https://issuer.example.com"
|
||||
cfg.OIDC.AuthorizeURL = "https://issuer.example.com/auth"
|
||||
cfg.OIDC.TokenURL = "https://issuer.example.com/token"
|
||||
cfg.OIDC.JWKSURL = "https://issuer.example.com/jwks"
|
||||
cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback"
|
||||
cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback"
|
||||
cfg.OIDC.Scopes = "profile email"
|
||||
|
||||
err = cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() expected error when scopes do not include openid, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "oidc_connect.scopes") {
|
||||
t.Fatalf("Validate() expected oidc_connect.scopes error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOIDCAllowsIssuerOnlyEndpointsWithDiscoveryFallback(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
cfg.OIDC.Enabled = true
|
||||
cfg.OIDC.ClientID = "oidc-client"
|
||||
cfg.OIDC.ClientSecret = "oidc-secret"
|
||||
cfg.OIDC.IssuerURL = "https://issuer.example.com"
|
||||
cfg.OIDC.AuthorizeURL = ""
|
||||
cfg.OIDC.TokenURL = ""
|
||||
cfg.OIDC.JWKSURL = ""
|
||||
cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback"
|
||||
cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback"
|
||||
cfg.OIDC.Scopes = "openid email profile"
|
||||
cfg.OIDC.ValidateIDToken = true
|
||||
|
||||
err = cfg.Validate()
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() expected issuer-only OIDC config to pass with discovery fallback, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultDashboardCacheConfig(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
|
||||
10
backend/internal/domain/openai_messages_dispatch.go
Normal file
10
backend/internal/domain/openai_messages_dispatch.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
// OpenAIMessagesDispatchModelConfig controls how Anthropic /v1/messages
|
||||
// requests are mapped onto OpenAI/Codex models.
|
||||
type OpenAIMessagesDispatchModelConfig struct {
|
||||
OpusMappedModel string `json:"opus_mapped_model,omitempty"`
|
||||
SonnetMappedModel string `json:"sonnet_mapped_model,omitempty"`
|
||||
HaikuMappedModel string `json:"haiku_mapped_model,omitempty"`
|
||||
ExactModelMappings map[string]string `json:"exact_model_mappings,omitempty"`
|
||||
}
|
||||
@@ -105,10 +105,11 @@ type CreateGroupRequest struct {
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
}
|
||||
@@ -139,10 +140,11 @@ type UpdateGroupRequest struct {
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes *[]string `json:"supported_model_scopes"`
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
}
|
||||
@@ -259,6 +261,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -309,6 +312,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -35,6 +35,15 @@ func generateMenuItemID() (string, error) {
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func scopesContainOpenID(scopes string) bool {
|
||||
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
|
||||
if scope == "openid" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SettingHandler 系统设置处理器
|
||||
type SettingHandler struct {
|
||||
settingService *service.SettingService
|
||||
@@ -96,6 +105,28 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
|
||||
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
|
||||
OIDCConnectEnabled: settings.OIDCConnectEnabled,
|
||||
OIDCConnectProviderName: settings.OIDCConnectProviderName,
|
||||
OIDCConnectClientID: settings.OIDCConnectClientID,
|
||||
OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured,
|
||||
OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL,
|
||||
OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL,
|
||||
OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL,
|
||||
OIDCConnectTokenURL: settings.OIDCConnectTokenURL,
|
||||
OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL,
|
||||
OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL,
|
||||
OIDCConnectScopes: settings.OIDCConnectScopes,
|
||||
OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL,
|
||||
OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL,
|
||||
OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod,
|
||||
OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE,
|
||||
OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken,
|
||||
OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs,
|
||||
OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds,
|
||||
OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified,
|
||||
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
|
||||
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
|
||||
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
@@ -166,6 +197,30 @@ type UpdateSettingsRequest struct {
|
||||
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
||||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||||
|
||||
// Generic OIDC OAuth 登录
|
||||
OIDCConnectEnabled bool `json:"oidc_connect_enabled"`
|
||||
OIDCConnectProviderName string `json:"oidc_connect_provider_name"`
|
||||
OIDCConnectClientID string `json:"oidc_connect_client_id"`
|
||||
OIDCConnectClientSecret string `json:"oidc_connect_client_secret"`
|
||||
OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"`
|
||||
OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"`
|
||||
OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"`
|
||||
OIDCConnectTokenURL string `json:"oidc_connect_token_url"`
|
||||
OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"`
|
||||
OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"`
|
||||
OIDCConnectScopes string `json:"oidc_connect_scopes"`
|
||||
OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"`
|
||||
OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"`
|
||||
OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"`
|
||||
OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"`
|
||||
OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"`
|
||||
OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"`
|
||||
OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"`
|
||||
OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"`
|
||||
OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"`
|
||||
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
||||
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
|
||||
|
||||
// OEM设置
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
@@ -335,6 +390,122 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generic OIDC 参数验证
|
||||
if req.OIDCConnectEnabled {
|
||||
req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName)
|
||||
req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID)
|
||||
req.OIDCConnectClientSecret = strings.TrimSpace(req.OIDCConnectClientSecret)
|
||||
req.OIDCConnectIssuerURL = strings.TrimSpace(req.OIDCConnectIssuerURL)
|
||||
req.OIDCConnectDiscoveryURL = strings.TrimSpace(req.OIDCConnectDiscoveryURL)
|
||||
req.OIDCConnectAuthorizeURL = strings.TrimSpace(req.OIDCConnectAuthorizeURL)
|
||||
req.OIDCConnectTokenURL = strings.TrimSpace(req.OIDCConnectTokenURL)
|
||||
req.OIDCConnectUserInfoURL = strings.TrimSpace(req.OIDCConnectUserInfoURL)
|
||||
req.OIDCConnectJWKSURL = strings.TrimSpace(req.OIDCConnectJWKSURL)
|
||||
req.OIDCConnectScopes = strings.TrimSpace(req.OIDCConnectScopes)
|
||||
req.OIDCConnectRedirectURL = strings.TrimSpace(req.OIDCConnectRedirectURL)
|
||||
req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(req.OIDCConnectFrontendRedirectURL)
|
||||
req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(req.OIDCConnectTokenAuthMethod))
|
||||
req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(req.OIDCConnectAllowedSigningAlgs)
|
||||
req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath)
|
||||
req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath)
|
||||
req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath)
|
||||
|
||||
if req.OIDCConnectProviderName == "" {
|
||||
req.OIDCConnectProviderName = "OIDC"
|
||||
}
|
||||
if req.OIDCConnectClientID == "" {
|
||||
response.BadRequest(c, "OIDC Client ID is required when enabled")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectIssuerURL == "" {
|
||||
response.BadRequest(c, "OIDC Issuer URL is required when enabled")
|
||||
return
|
||||
}
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectIssuerURL); err != nil {
|
||||
response.BadRequest(c, "OIDC Issuer URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectDiscoveryURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectDiscoveryURL); err != nil {
|
||||
response.BadRequest(c, "OIDC Discovery URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OIDCConnectAuthorizeURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectAuthorizeURL); err != nil {
|
||||
response.BadRequest(c, "OIDC Authorize URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OIDCConnectTokenURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectTokenURL); err != nil {
|
||||
response.BadRequest(c, "OIDC Token URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OIDCConnectUserInfoURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectUserInfoURL); err != nil {
|
||||
response.BadRequest(c, "OIDC UserInfo URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OIDCConnectRedirectURL == "" {
|
||||
response.BadRequest(c, "OIDC Redirect URL is required when enabled")
|
||||
return
|
||||
}
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectRedirectURL); err != nil {
|
||||
response.BadRequest(c, "OIDC Redirect URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectFrontendRedirectURL == "" {
|
||||
response.BadRequest(c, "OIDC Frontend Redirect URL is required when enabled")
|
||||
return
|
||||
}
|
||||
if err := config.ValidateFrontendRedirectURL(req.OIDCConnectFrontendRedirectURL); err != nil {
|
||||
response.BadRequest(c, "OIDC Frontend Redirect URL is invalid")
|
||||
return
|
||||
}
|
||||
if !scopesContainOpenID(req.OIDCConnectScopes) {
|
||||
response.BadRequest(c, "OIDC scopes must contain openid")
|
||||
return
|
||||
}
|
||||
switch req.OIDCConnectTokenAuthMethod {
|
||||
case "", "client_secret_post", "client_secret_basic", "none":
|
||||
default:
|
||||
response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectTokenAuthMethod == "none" && !req.OIDCConnectUsePKCE {
|
||||
response.BadRequest(c, "OIDC PKCE must be enabled when token_auth_method=none")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 {
|
||||
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
||||
return
|
||||
}
|
||||
if req.OIDCConnectValidateIDToken {
|
||||
if req.OIDCConnectAllowedSigningAlgs == "" {
|
||||
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OIDCConnectJWKSURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil {
|
||||
response.BadRequest(c, "OIDC JWKS URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OIDCConnectTokenAuthMethod == "" || req.OIDCConnectTokenAuthMethod == "client_secret_post" || req.OIDCConnectTokenAuthMethod == "client_secret_basic" {
|
||||
if req.OIDCConnectClientSecret == "" {
|
||||
if previousSettings.OIDCConnectClientSecret == "" {
|
||||
response.BadRequest(c, "OIDC Client Secret is required when enabled")
|
||||
return
|
||||
}
|
||||
req.OIDCConnectClientSecret = previousSettings.OIDCConnectClientSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// “购买订阅”页面配置验证
|
||||
purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled
|
||||
if req.PurchaseSubscriptionEnabled != nil {
|
||||
@@ -565,6 +736,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||||
OIDCConnectEnabled: req.OIDCConnectEnabled,
|
||||
OIDCConnectProviderName: req.OIDCConnectProviderName,
|
||||
OIDCConnectClientID: req.OIDCConnectClientID,
|
||||
OIDCConnectClientSecret: req.OIDCConnectClientSecret,
|
||||
OIDCConnectIssuerURL: req.OIDCConnectIssuerURL,
|
||||
OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL,
|
||||
OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL,
|
||||
OIDCConnectTokenURL: req.OIDCConnectTokenURL,
|
||||
OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL,
|
||||
OIDCConnectJWKSURL: req.OIDCConnectJWKSURL,
|
||||
OIDCConnectScopes: req.OIDCConnectScopes,
|
||||
OIDCConnectRedirectURL: req.OIDCConnectRedirectURL,
|
||||
OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL,
|
||||
OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod,
|
||||
OIDCConnectUsePKCE: req.OIDCConnectUsePKCE,
|
||||
OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken,
|
||||
OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs,
|
||||
OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds,
|
||||
OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified,
|
||||
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
|
||||
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
|
||||
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
|
||||
SiteName: req.SiteName,
|
||||
SiteLogo: req.SiteLogo,
|
||||
SiteSubtitle: req.SiteSubtitle,
|
||||
@@ -682,6 +875,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
||||
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
||||
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
|
||||
OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled,
|
||||
OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName,
|
||||
OIDCConnectClientID: updatedSettings.OIDCConnectClientID,
|
||||
OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured,
|
||||
OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL,
|
||||
OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL,
|
||||
OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL,
|
||||
OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL,
|
||||
OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL,
|
||||
OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL,
|
||||
OIDCConnectScopes: updatedSettings.OIDCConnectScopes,
|
||||
OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL,
|
||||
OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL,
|
||||
OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod,
|
||||
OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE,
|
||||
OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken,
|
||||
OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs,
|
||||
OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds,
|
||||
OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified,
|
||||
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
|
||||
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
|
||||
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
|
||||
SiteName: updatedSettings.SiteName,
|
||||
SiteLogo: updatedSettings.SiteLogo,
|
||||
SiteSubtitle: updatedSettings.SiteSubtitle,
|
||||
@@ -802,6 +1017,72 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
|
||||
changed = append(changed, "linuxdo_connect_redirect_url")
|
||||
}
|
||||
if before.OIDCConnectEnabled != after.OIDCConnectEnabled {
|
||||
changed = append(changed, "oidc_connect_enabled")
|
||||
}
|
||||
if before.OIDCConnectProviderName != after.OIDCConnectProviderName {
|
||||
changed = append(changed, "oidc_connect_provider_name")
|
||||
}
|
||||
if before.OIDCConnectClientID != after.OIDCConnectClientID {
|
||||
changed = append(changed, "oidc_connect_client_id")
|
||||
}
|
||||
if req.OIDCConnectClientSecret != "" {
|
||||
changed = append(changed, "oidc_connect_client_secret")
|
||||
}
|
||||
if before.OIDCConnectIssuerURL != after.OIDCConnectIssuerURL {
|
||||
changed = append(changed, "oidc_connect_issuer_url")
|
||||
}
|
||||
if before.OIDCConnectDiscoveryURL != after.OIDCConnectDiscoveryURL {
|
||||
changed = append(changed, "oidc_connect_discovery_url")
|
||||
}
|
||||
if before.OIDCConnectAuthorizeURL != after.OIDCConnectAuthorizeURL {
|
||||
changed = append(changed, "oidc_connect_authorize_url")
|
||||
}
|
||||
if before.OIDCConnectTokenURL != after.OIDCConnectTokenURL {
|
||||
changed = append(changed, "oidc_connect_token_url")
|
||||
}
|
||||
if before.OIDCConnectUserInfoURL != after.OIDCConnectUserInfoURL {
|
||||
changed = append(changed, "oidc_connect_userinfo_url")
|
||||
}
|
||||
if before.OIDCConnectJWKSURL != after.OIDCConnectJWKSURL {
|
||||
changed = append(changed, "oidc_connect_jwks_url")
|
||||
}
|
||||
if before.OIDCConnectScopes != after.OIDCConnectScopes {
|
||||
changed = append(changed, "oidc_connect_scopes")
|
||||
}
|
||||
if before.OIDCConnectRedirectURL != after.OIDCConnectRedirectURL {
|
||||
changed = append(changed, "oidc_connect_redirect_url")
|
||||
}
|
||||
if before.OIDCConnectFrontendRedirectURL != after.OIDCConnectFrontendRedirectURL {
|
||||
changed = append(changed, "oidc_connect_frontend_redirect_url")
|
||||
}
|
||||
if before.OIDCConnectTokenAuthMethod != after.OIDCConnectTokenAuthMethod {
|
||||
changed = append(changed, "oidc_connect_token_auth_method")
|
||||
}
|
||||
if before.OIDCConnectUsePKCE != after.OIDCConnectUsePKCE {
|
||||
changed = append(changed, "oidc_connect_use_pkce")
|
||||
}
|
||||
if before.OIDCConnectValidateIDToken != after.OIDCConnectValidateIDToken {
|
||||
changed = append(changed, "oidc_connect_validate_id_token")
|
||||
}
|
||||
if before.OIDCConnectAllowedSigningAlgs != after.OIDCConnectAllowedSigningAlgs {
|
||||
changed = append(changed, "oidc_connect_allowed_signing_algs")
|
||||
}
|
||||
if before.OIDCConnectClockSkewSeconds != after.OIDCConnectClockSkewSeconds {
|
||||
changed = append(changed, "oidc_connect_clock_skew_seconds")
|
||||
}
|
||||
if before.OIDCConnectRequireEmailVerified != after.OIDCConnectRequireEmailVerified {
|
||||
changed = append(changed, "oidc_connect_require_email_verified")
|
||||
}
|
||||
if before.OIDCConnectUserInfoEmailPath != after.OIDCConnectUserInfoEmailPath {
|
||||
changed = append(changed, "oidc_connect_userinfo_email_path")
|
||||
}
|
||||
if before.OIDCConnectUserInfoIDPath != after.OIDCConnectUserInfoIDPath {
|
||||
changed = append(changed, "oidc_connect_userinfo_id_path")
|
||||
}
|
||||
if before.OIDCConnectUserInfoUsernamePath != after.OIDCConnectUserInfoUsernamePath {
|
||||
changed = append(changed, "oidc_connect_userinfo_username_path")
|
||||
}
|
||||
if before.SiteName != after.SiteName {
|
||||
changed = append(changed, "site_name")
|
||||
}
|
||||
|
||||
873
backend/internal/handler/auth_oidc_oauth.go
Normal file
873
backend/internal/handler/auth_oidc_oauth.go
Normal file
@@ -0,0 +1,873 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
oidcOAuthCookiePath = "/api/v1/auth/oauth/oidc"
|
||||
oidcOAuthStateCookieName = "oidc_oauth_state"
|
||||
oidcOAuthVerifierCookie = "oidc_oauth_verifier"
|
||||
oidcOAuthRedirectCookie = "oidc_oauth_redirect"
|
||||
oidcOAuthNonceCookie = "oidc_oauth_nonce"
|
||||
oidcOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
|
||||
oidcOAuthDefaultRedirectTo = "/dashboard"
|
||||
oidcOAuthDefaultFrontendCB = "/auth/oidc/callback"
|
||||
)
|
||||
|
||||
type oidcTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
|
||||
type oidcTokenExchangeError struct {
|
||||
StatusCode int
|
||||
ProviderError string
|
||||
ProviderDescription string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *oidcTokenExchangeError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)}
|
||||
if strings.TrimSpace(e.ProviderError) != "" {
|
||||
parts = append(parts, "error="+strings.TrimSpace(e.ProviderError))
|
||||
}
|
||||
if strings.TrimSpace(e.ProviderDescription) != "" {
|
||||
parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
type oidcIDTokenClaims struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified *bool `json:"email_verified,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Azp string `json:"azp,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type oidcUserInfoClaims struct {
|
||||
Email string
|
||||
Username string
|
||||
Subject string
|
||||
EmailVerified *bool
|
||||
}
|
||||
|
||||
type oidcJWKSet struct {
|
||||
Keys []oidcJWK `json:"keys"`
|
||||
}
|
||||
|
||||
type oidcJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
|
||||
func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
||||
cfg, err := h.getOIDCOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := oauth.GenerateState()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
|
||||
if redirectTo == "" {
|
||||
redirectTo = oidcOAuthDefaultRedirectTo
|
||||
}
|
||||
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
oidcSetCookie(c, oidcOAuthStateCookieName, encodeCookieValue(state), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||
oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||
|
||||
codeChallenge := ""
|
||||
if cfg.UsePKCE {
|
||||
verifier, genErr := oauth.GenerateCodeVerifier()
|
||||
if genErr != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(genErr))
|
||||
return
|
||||
}
|
||||
codeChallenge = oauth.GenerateCodeChallenge(verifier)
|
||||
oidcSetCookie(c, oidcOAuthVerifierCookie, encodeCookieValue(verifier), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||
}
|
||||
|
||||
nonce := ""
|
||||
if cfg.ValidateIDToken {
|
||||
nonce, err = oauth.GenerateState()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_NONCE_GEN_FAILED", "failed to generate oauth nonce").WithCause(err))
|
||||
return
|
||||
}
|
||||
oidcSetCookie(c, oidcOAuthNonceCookie, encodeCookieValue(nonce), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||
}
|
||||
|
||||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||||
if redirectURI == "" {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
authURL, err := buildOIDCAuthorizeURL(cfg, state, nonce, codeChallenge, redirectURI)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。
|
||||
// GET /api/v1/auth/oauth/oidc/callback?code=...&state=...
|
||||
func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
cfg, cfgErr := h.getOIDCOAuthConfig(c.Request.Context())
|
||||
if cfgErr != nil {
|
||||
response.ErrorFrom(c, cfgErr)
|
||||
return
|
||||
}
|
||||
|
||||
frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
|
||||
if frontendCallback == "" {
|
||||
frontendCallback = oidcOAuthDefaultFrontendCB
|
||||
}
|
||||
|
||||
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
|
||||
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
if code == "" || state == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
|
||||
return
|
||||
}
|
||||
|
||||
secureCookie := isRequestHTTPS(c)
|
||||
defer func() {
|
||||
oidcClearCookie(c, oidcOAuthStateCookieName, secureCookie)
|
||||
oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie)
|
||||
oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie)
|
||||
oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
|
||||
}()
|
||||
|
||||
expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
|
||||
if err != nil || expectedState == "" || state != expectedState {
|
||||
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo, _ := readCookieDecoded(c, oidcOAuthRedirectCookie)
|
||||
redirectTo = sanitizeFrontendRedirectPath(redirectTo)
|
||||
if redirectTo == "" {
|
||||
redirectTo = oidcOAuthDefaultRedirectTo
|
||||
}
|
||||
|
||||
codeVerifier := ""
|
||||
if cfg.UsePKCE {
|
||||
codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie)
|
||||
if codeVerifier == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
expectedNonce := ""
|
||||
if cfg.ValidateIDToken {
|
||||
expectedNonce, _ = readCookieDecoded(c, oidcOAuthNonceCookie)
|
||||
if expectedNonce == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_nonce", "missing oauth nonce", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||||
if redirectURI == "" {
|
||||
redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "")
|
||||
return
|
||||
}
|
||||
|
||||
tokenResp, err := oidcExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
|
||||
if err != nil {
|
||||
description := ""
|
||||
var exchangeErr *oidcTokenExchangeError
|
||||
if errors.As(err, &exchangeErr) && exchangeErr != nil {
|
||||
log.Printf(
|
||||
"[OIDC OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s",
|
||||
exchangeErr.StatusCode,
|
||||
exchangeErr.ProviderError,
|
||||
exchangeErr.ProviderDescription,
|
||||
truncateLogValue(exchangeErr.Body, 2048),
|
||||
)
|
||||
description = exchangeErr.Error()
|
||||
} else {
|
||||
log.Printf("[OIDC OAuth] token exchange failed: %v", err)
|
||||
description = err.Error()
|
||||
}
|
||||
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description))
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.ValidateIDToken && strings.TrimSpace(tokenResp.IDToken) == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_id_token", "missing id_token", "")
|
||||
return
|
||||
}
|
||||
|
||||
idClaims, err := oidcParseAndValidateIDToken(c.Request.Context(), cfg, tokenResp.IDToken, expectedNonce)
|
||||
if err != nil {
|
||||
log.Printf("[OIDC OAuth] id_token validation failed: %v", err)
|
||||
redirectOAuthError(c, frontendCallback, "invalid_id_token", "failed to validate id_token", "")
|
||||
return
|
||||
}
|
||||
|
||||
userInfoClaims, err := oidcFetchUserInfo(c.Request.Context(), cfg, tokenResp)
|
||||
if err != nil {
|
||||
log.Printf("[OIDC OAuth] userinfo fetch failed: %v", err)
|
||||
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
|
||||
return
|
||||
}
|
||||
|
||||
subject := strings.TrimSpace(idClaims.Subject)
|
||||
if subject == "" {
|
||||
subject = strings.TrimSpace(userInfoClaims.Subject)
|
||||
}
|
||||
if subject == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_subject", "missing subject claim", "")
|
||||
return
|
||||
}
|
||||
issuer := strings.TrimSpace(idClaims.Issuer)
|
||||
if issuer == "" {
|
||||
issuer = strings.TrimSpace(cfg.IssuerURL)
|
||||
}
|
||||
if issuer == "" {
|
||||
redirectOAuthError(c, frontendCallback, "missing_issuer", "missing issuer claim", "")
|
||||
return
|
||||
}
|
||||
|
||||
emailVerified := userInfoClaims.EmailVerified
|
||||
if emailVerified == nil {
|
||||
emailVerified = idClaims.EmailVerified
|
||||
}
|
||||
if cfg.RequireEmailVerified {
|
||||
if emailVerified == nil || !*emailVerified {
|
||||
redirectOAuthError(c, frontendCallback, "email_not_verified", "email is not verified", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
identityKey := oidcIdentityKey(issuer, subject)
|
||||
email := oidcSelectLoginEmail(userInfoClaims.Email, idClaims.Email, identityKey)
|
||||
username := firstNonEmpty(
|
||||
userInfoClaims.Username,
|
||||
idClaims.PreferredUsername,
|
||||
idClaims.Name,
|
||||
oidcFallbackUsername(subject),
|
||||
)
|
||||
|
||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
||||
pendingToken, tokenErr := h.authService.CreatePendingOAuthToken(email, username)
|
||||
if tokenErr != nil {
|
||||
redirectOAuthError(c, frontendCallback, "login_failed", "service_error", "")
|
||||
return
|
||||
}
|
||||
fragment := url.Values{}
|
||||
fragment.Set("error", "invitation_required")
|
||||
fragment.Set("pending_oauth_token", pendingToken)
|
||||
fragment.Set("redirect", redirectTo)
|
||||
redirectWithFragment(c, frontendCallback, fragment)
|
||||
return
|
||||
}
|
||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
|
||||
fragment := url.Values{}
|
||||
fragment.Set("access_token", tokenPair.AccessToken)
|
||||
fragment.Set("refresh_token", tokenPair.RefreshToken)
|
||||
fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn))
|
||||
fragment.Set("token_type", "Bearer")
|
||||
fragment.Set("redirect", redirectTo)
|
||||
redirectWithFragment(c, frontendCallback, fragment)
|
||||
}
|
||||
|
||||
type completeOIDCOAuthRequest struct {
|
||||
PendingOAuthToken string `json:"pending_oauth_token" binding:"required"`
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
}
|
||||
|
||||
// CompleteOIDCOAuthRegistration completes a pending OAuth registration by validating
|
||||
// the invitation code and creating the user account.
|
||||
// POST /api/v1/auth/oauth/oidc/complete-registration
|
||||
func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
|
||||
var req completeOIDCOAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
email, username, err := h.authService.VerifyPendingOAuthToken(req.PendingOAuthToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "INVALID_TOKEN", "message": "invalid or expired registration token"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": tokenPair.AccessToken,
|
||||
"refresh_token": tokenPair.RefreshToken,
|
||||
"expires_in": tokenPair.ExpiresIn,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getOIDCOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
|
||||
if h != nil && h.settingSvc != nil {
|
||||
return h.settingSvc.GetOIDCConnectOAuthConfig(ctx)
|
||||
}
|
||||
if h == nil || h.cfg == nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||||
}
|
||||
if !h.cfg.OIDC.Enabled {
|
||||
return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||||
}
|
||||
return h.cfg.OIDC, nil
|
||||
}
|
||||
|
||||
func oidcExchangeCode(
|
||||
ctx context.Context,
|
||||
cfg config.OIDCConnectConfig,
|
||||
code string,
|
||||
redirectURI string,
|
||||
codeVerifier string,
|
||||
) (*oidcTokenResponse, error) {
|
||||
client := req.C().SetTimeout(30 * time.Second)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
if cfg.UsePKCE {
|
||||
form.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
|
||||
r := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json")
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) {
|
||||
case "", "client_secret_post":
|
||||
form.Set("client_secret", cfg.ClientSecret)
|
||||
case "client_secret_basic":
|
||||
r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret)
|
||||
case "none":
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
|
||||
}
|
||||
|
||||
resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request token: %w", err)
|
||||
}
|
||||
body := strings.TrimSpace(resp.String())
|
||||
if !resp.IsSuccessState() {
|
||||
providerErr, providerDesc := parseOAuthProviderError(body)
|
||||
return nil, &oidcTokenExchangeError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ProviderError: providerErr,
|
||||
ProviderDescription: providerDesc,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
tokenResp, ok := oidcParseTokenResponse(body)
|
||||
if !ok {
|
||||
return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body}
|
||||
}
|
||||
if strings.TrimSpace(tokenResp.TokenType) == "" {
|
||||
tokenResp.TokenType = "Bearer"
|
||||
}
|
||||
if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" {
|
||||
return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body}
|
||||
}
|
||||
return tokenResp, nil
|
||||
}
|
||||
|
||||
func oidcParseTokenResponse(body string) (*oidcTokenResponse, bool) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
accessToken := strings.TrimSpace(getGJSON(body, "access_token"))
|
||||
idToken := strings.TrimSpace(getGJSON(body, "id_token"))
|
||||
if accessToken != "" || idToken != "" {
|
||||
tokenType := strings.TrimSpace(getGJSON(body, "token_type"))
|
||||
refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token"))
|
||||
scope := strings.TrimSpace(getGJSON(body, "scope"))
|
||||
expiresIn := gjson.Get(body, "expires_in").Int()
|
||||
return &oidcTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: tokenType,
|
||||
ExpiresIn: expiresIn,
|
||||
RefreshToken: refreshToken,
|
||||
Scope: scope,
|
||||
IDToken: idToken,
|
||||
}, true
|
||||
}
|
||||
|
||||
values, err := url.ParseQuery(body)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
accessToken = strings.TrimSpace(values.Get("access_token"))
|
||||
idToken = strings.TrimSpace(values.Get("id_token"))
|
||||
if accessToken == "" && idToken == "" {
|
||||
return nil, false
|
||||
}
|
||||
expiresIn := int64(0)
|
||||
if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" {
|
||||
if v, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil {
|
||||
expiresIn = v
|
||||
}
|
||||
}
|
||||
return &oidcTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: strings.TrimSpace(values.Get("token_type")),
|
||||
ExpiresIn: expiresIn,
|
||||
RefreshToken: strings.TrimSpace(values.Get("refresh_token")),
|
||||
Scope: strings.TrimSpace(values.Get("scope")),
|
||||
IDToken: idToken,
|
||||
}, true
|
||||
}
|
||||
|
||||
func oidcFetchUserInfo(
|
||||
ctx context.Context,
|
||||
cfg config.OIDCConnectConfig,
|
||||
token *oidcTokenResponse,
|
||||
) (*oidcUserInfoClaims, error) {
|
||||
if strings.TrimSpace(cfg.UserInfoURL) == "" {
|
||||
return &oidcUserInfoClaims{}, nil
|
||||
}
|
||||
if token == nil || strings.TrimSpace(token.AccessToken) == "" {
|
||||
return nil, errors.New("missing access_token for userinfo request")
|
||||
}
|
||||
|
||||
client := req.C().SetTimeout(30 * time.Second)
|
||||
authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token for userinfo request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetHeader("Authorization", authorization).
|
||||
Get(cfg.UserInfoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request userinfo: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("userinfo status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return oidcParseUserInfo(resp.String(), cfg), nil
|
||||
}
|
||||
|
||||
func oidcParseUserInfo(body string, cfg config.OIDCConnectConfig) *oidcUserInfoClaims {
|
||||
claims := &oidcUserInfoClaims{}
|
||||
claims.Email = firstNonEmpty(
|
||||
getGJSON(body, cfg.UserInfoEmailPath),
|
||||
getGJSON(body, "email"),
|
||||
getGJSON(body, "user.email"),
|
||||
getGJSON(body, "data.email"),
|
||||
getGJSON(body, "attributes.email"),
|
||||
)
|
||||
claims.Username = firstNonEmpty(
|
||||
getGJSON(body, cfg.UserInfoUsernamePath),
|
||||
getGJSON(body, "preferred_username"),
|
||||
getGJSON(body, "username"),
|
||||
getGJSON(body, "name"),
|
||||
getGJSON(body, "user.username"),
|
||||
getGJSON(body, "user.name"),
|
||||
)
|
||||
claims.Subject = firstNonEmpty(
|
||||
getGJSON(body, cfg.UserInfoIDPath),
|
||||
getGJSON(body, "sub"),
|
||||
getGJSON(body, "id"),
|
||||
getGJSON(body, "user_id"),
|
||||
getGJSON(body, "uid"),
|
||||
getGJSON(body, "user.id"),
|
||||
)
|
||||
if verified, ok := getGJSONBool(body, "email_verified"); ok {
|
||||
claims.EmailVerified = &verified
|
||||
}
|
||||
claims.Email = strings.TrimSpace(claims.Email)
|
||||
claims.Username = strings.TrimSpace(claims.Username)
|
||||
claims.Subject = strings.TrimSpace(claims.Subject)
|
||||
return claims
|
||||
}
|
||||
|
||||
func getGJSONBool(body string, path string) (bool, bool) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false, false
|
||||
}
|
||||
res := gjson.Get(body, path)
|
||||
if !res.Exists() {
|
||||
return false, false
|
||||
}
|
||||
return res.Bool(), true
|
||||
}
|
||||
|
||||
func buildOIDCAuthorizeURL(cfg config.OIDCConnectConfig, state, nonce, codeChallenge, redirectURI string) (string, error) {
|
||||
u, err := url.Parse(cfg.AuthorizeURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse authorize_url: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", cfg.ClientID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
if strings.TrimSpace(cfg.Scopes) != "" {
|
||||
q.Set("scope", cfg.Scopes)
|
||||
}
|
||||
q.Set("state", state)
|
||||
if strings.TrimSpace(nonce) != "" {
|
||||
q.Set("nonce", nonce)
|
||||
}
|
||||
if cfg.UsePKCE {
|
||||
q.Set("code_challenge", codeChallenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func oidcParseAndValidateIDToken(ctx context.Context, cfg config.OIDCConnectConfig, idToken string, expectedNonce string) (*oidcIDTokenClaims, error) {
|
||||
idToken = strings.TrimSpace(idToken)
|
||||
if idToken == "" {
|
||||
return nil, errors.New("missing id_token")
|
||||
}
|
||||
allowed := oidcAllowedSigningAlgs(cfg.AllowedSigningAlgs)
|
||||
if len(allowed) == 0 {
|
||||
return nil, errors.New("empty allowed signing algorithms")
|
||||
}
|
||||
|
||||
jwks, err := oidcFetchJWKSet(ctx, cfg.JWKSURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leeway := time.Duration(cfg.ClockSkewSeconds) * time.Second
|
||||
claims := &oidcIDTokenClaims{}
|
||||
|
||||
parsed, err := jwt.ParseWithClaims(
|
||||
idToken,
|
||||
claims,
|
||||
func(token *jwt.Token) (any, error) {
|
||||
alg := strings.TrimSpace(token.Method.Alg())
|
||||
if !containsString(allowed, alg) {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %s", alg)
|
||||
}
|
||||
kid, _ := token.Header["kid"].(string)
|
||||
return oidcFindPublicKey(jwks, strings.TrimSpace(kid), alg)
|
||||
},
|
||||
jwt.WithValidMethods(allowed),
|
||||
jwt.WithAudience(cfg.ClientID),
|
||||
jwt.WithIssuer(cfg.IssuerURL),
|
||||
jwt.WithLeeway(leeway),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return nil, errors.New("id_token invalid")
|
||||
}
|
||||
if strings.TrimSpace(claims.Subject) == "" {
|
||||
return nil, errors.New("id_token missing sub")
|
||||
}
|
||||
if expectedNonce != "" && strings.TrimSpace(claims.Nonce) != strings.TrimSpace(expectedNonce) {
|
||||
return nil, errors.New("id_token nonce mismatch")
|
||||
}
|
||||
if len(claims.Audience) > 1 {
|
||||
if strings.TrimSpace(claims.Azp) == "" || strings.TrimSpace(claims.Azp) != strings.TrimSpace(cfg.ClientID) {
|
||||
return nil, errors.New("id_token azp mismatch")
|
||||
}
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func oidcAllowedSigningAlgs(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{"RS256", "ES256", "PS256"}
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, 4)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
alg := strings.ToUpper(strings.TrimSpace(part))
|
||||
if alg == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[alg]; ok {
|
||||
continue
|
||||
}
|
||||
seen[alg] = struct{}{}
|
||||
out = append(out, alg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func oidcFetchJWKSet(ctx context.Context, jwksURL string) (*oidcJWKSet, error) {
|
||||
jwksURL = strings.TrimSpace(jwksURL)
|
||||
if jwksURL == "" {
|
||||
return nil, errors.New("missing jwks_url")
|
||||
}
|
||||
resp, err := req.C().
|
||||
SetTimeout(30*time.Second).
|
||||
R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json").
|
||||
Get(jwksURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request jwks: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("jwks status=%d", resp.StatusCode)
|
||||
}
|
||||
set := &oidcJWKSet{}
|
||||
if err := json.Unmarshal(resp.Bytes(), set); err != nil {
|
||||
return nil, fmt.Errorf("parse jwks: %w", err)
|
||||
}
|
||||
if len(set.Keys) == 0 {
|
||||
return nil, errors.New("jwks empty keys")
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func oidcFindPublicKey(set *oidcJWKSet, kid, alg string) (any, error) {
|
||||
if set == nil {
|
||||
return nil, errors.New("jwks not loaded")
|
||||
}
|
||||
alg = strings.ToUpper(strings.TrimSpace(alg))
|
||||
kid = strings.TrimSpace(kid)
|
||||
|
||||
var lastErr error
|
||||
for i := range set.Keys {
|
||||
k := set.Keys[i]
|
||||
if strings.TrimSpace(k.Use) != "" && !strings.EqualFold(strings.TrimSpace(k.Use), "sig") {
|
||||
continue
|
||||
}
|
||||
if kid != "" && strings.TrimSpace(k.Kid) != kid {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(k.Alg) != "" && !strings.EqualFold(strings.TrimSpace(k.Alg), alg) {
|
||||
continue
|
||||
}
|
||||
pk, err := k.publicKey()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if pk != nil {
|
||||
return pk, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
if kid != "" {
|
||||
return nil, fmt.Errorf("jwk not found for kid=%s", kid)
|
||||
}
|
||||
return nil, errors.New("jwk not found")
|
||||
}
|
||||
|
||||
func (k oidcJWK) publicKey() (any, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(k.Kty)) {
|
||||
case "RSA":
|
||||
n, err := decodeBase64URLBigInt(k.N)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode rsa n: %w", err)
|
||||
}
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(k.E))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode rsa e: %w", err)
|
||||
}
|
||||
if len(eBytes) == 0 {
|
||||
return nil, errors.New("empty rsa e")
|
||||
}
|
||||
e := 0
|
||||
for _, b := range eBytes {
|
||||
e = (e << 8) | int(b)
|
||||
}
|
||||
if e <= 0 {
|
||||
return nil, errors.New("invalid rsa exponent")
|
||||
}
|
||||
if n.Sign() <= 0 {
|
||||
return nil, errors.New("invalid rsa modulus")
|
||||
}
|
||||
return &rsa.PublicKey{N: n, E: e}, nil
|
||||
case "EC":
|
||||
var curve elliptic.Curve
|
||||
switch strings.TrimSpace(k.Crv) {
|
||||
case "P-256":
|
||||
curve = elliptic.P256()
|
||||
case "P-384":
|
||||
curve = elliptic.P384()
|
||||
case "P-521":
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ec curve: %s", k.Crv)
|
||||
}
|
||||
x, err := decodeBase64URLBigInt(k.X)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ec x: %w", err)
|
||||
}
|
||||
y, err := decodeBase64URLBigInt(k.Y)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ec y: %w", err)
|
||||
}
|
||||
if !curve.IsOnCurve(x, y) {
|
||||
return nil, errors.New("ec point is not on curve")
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeBase64URLBigInt(raw string) (*big.Int, error) {
|
||||
buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
return nil, errors.New("empty value")
|
||||
}
|
||||
return new(big.Int).SetBytes(buf), nil
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(strings.TrimSpace(v), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func oidcIdentityKey(issuer, subject string) string {
|
||||
issuer = strings.TrimSpace(strings.ToLower(issuer))
|
||||
subject = strings.TrimSpace(subject)
|
||||
return issuer + "\x1f" + subject
|
||||
}
|
||||
|
||||
func oidcSyntheticEmailFromIdentityKey(identityKey string) string {
|
||||
identityKey = strings.TrimSpace(identityKey)
|
||||
if identityKey == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(identityKey))
|
||||
return "oidc-" + hex.EncodeToString(sum[:16]) + service.OIDCConnectSyntheticEmailDomain
|
||||
}
|
||||
|
||||
func oidcSelectLoginEmail(userInfoEmail, idTokenEmail, identityKey string) string {
|
||||
email := strings.TrimSpace(firstNonEmpty(userInfoEmail, idTokenEmail))
|
||||
if email != "" {
|
||||
return email
|
||||
}
|
||||
return oidcSyntheticEmailFromIdentityKey(identityKey)
|
||||
}
|
||||
|
||||
func oidcFallbackUsername(subject string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
return "oidc_user"
|
||||
}
|
||||
sum := sha256.Sum256([]byte(subject))
|
||||
return "oidc_" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func oidcSetCookie(c *gin.Context, name, value string, maxAgeSec int, secure bool) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: oidcOAuthCookiePath,
|
||||
MaxAge: maxAgeSec,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func oidcClearCookie(c *gin.Context, name string, secure bool) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: oidcOAuthCookiePath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
120
backend/internal/handler/auth_oidc_oauth_test.go
Normal file
120
backend/internal/handler/auth_oidc_oauth_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) {
|
||||
k1 := oidcIdentityKey("https://issuer.example.com", "subject-a")
|
||||
k2 := oidcIdentityKey("https://issuer.example.com", "subject-b")
|
||||
|
||||
e1 := oidcSyntheticEmailFromIdentityKey(k1)
|
||||
e1Again := oidcSyntheticEmailFromIdentityKey(k1)
|
||||
e2 := oidcSyntheticEmailFromIdentityKey(k2)
|
||||
|
||||
require.Equal(t, e1, e1Again)
|
||||
require.NotEqual(t, e1, e2)
|
||||
require.Contains(t, e1, "@oidc-connect.invalid")
|
||||
}
|
||||
|
||||
func TestOIDCSelectLoginEmailPrefersRealEmail(t *testing.T) {
|
||||
identityKey := oidcIdentityKey("https://issuer.example.com", "subject-a")
|
||||
|
||||
email := oidcSelectLoginEmail("user@example.com", "idtoken@example.com", identityKey)
|
||||
require.Equal(t, "user@example.com", email)
|
||||
|
||||
email = oidcSelectLoginEmail("", "idtoken@example.com", identityKey)
|
||||
require.Equal(t, "idtoken@example.com", email)
|
||||
|
||||
email = oidcSelectLoginEmail("", "", identityKey)
|
||||
require.Contains(t, email, "@oidc-connect.invalid")
|
||||
require.Equal(t, oidcSyntheticEmailFromIdentityKey(identityKey), email)
|
||||
}
|
||||
|
||||
func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) {
|
||||
cfg := config.OIDCConnectConfig{
|
||||
AuthorizeURL: "https://issuer.example.com/auth",
|
||||
ClientID: "cid",
|
||||
Scopes: "openid email profile",
|
||||
UsePKCE: true,
|
||||
}
|
||||
|
||||
u, err := buildOIDCAuthorizeURL(cfg, "state123", "nonce123", "challenge123", "https://app.example.com/callback")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, u, "nonce=nonce123")
|
||||
require.Contains(t, u, "code_challenge=challenge123")
|
||||
require.Contains(t, u, "code_challenge_method=S256")
|
||||
require.Contains(t, u, "scope=openid+email+profile")
|
||||
}
|
||||
|
||||
func TestOIDCParseAndValidateIDToken(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
kid := "kid-1"
|
||||
jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &priv.PublicKey)}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, json.NewEncoder(w).Encode(jwks))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Now()
|
||||
claims := oidcIDTokenClaims{
|
||||
Nonce: "nonce-ok",
|
||||
Azp: "client-1",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "https://issuer.example.com",
|
||||
Subject: "subject-1",
|
||||
Audience: jwt.ClaimStrings{"client-1", "another-aud"},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = kid
|
||||
signed, err := tok.SignedString(priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.OIDCConnectConfig{
|
||||
ClientID: "client-1",
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
JWKSURL: srv.URL,
|
||||
AllowedSigningAlgs: "RS256",
|
||||
ClockSkewSeconds: 120,
|
||||
}
|
||||
|
||||
parsed, err := oidcParseAndValidateIDToken(context.Background(), cfg, signed, "nonce-ok")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "subject-1", parsed.Subject)
|
||||
require.Equal(t, "https://issuer.example.com", parsed.Issuer)
|
||||
|
||||
_, err = oidcParseAndValidateIDToken(context.Background(), cfg, signed, "bad-nonce")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK {
|
||||
n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes())
|
||||
return oidcJWK{
|
||||
Kty: "RSA",
|
||||
Kid: kid,
|
||||
Use: "sig",
|
||||
Alg: "RS256",
|
||||
N: n,
|
||||
E: e,
|
||||
}
|
||||
}
|
||||
@@ -133,16 +133,17 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
return nil
|
||||
}
|
||||
out := &AdminGroup{
|
||||
Group: groupFromServiceBase(g),
|
||||
ModelRouting: g.ModelRouting,
|
||||
ModelRoutingEnabled: g.ModelRoutingEnabled,
|
||||
MCPXMLInject: g.MCPXMLInject,
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
SupportedModelScopes: g.SupportedModelScopes,
|
||||
AccountCount: g.AccountCount,
|
||||
ActiveAccountCount: g.ActiveAccountCount,
|
||||
RateLimitedAccountCount: g.RateLimitedAccountCount,
|
||||
SortOrder: g.SortOrder,
|
||||
Group: groupFromServiceBase(g),
|
||||
ModelRouting: g.ModelRouting,
|
||||
ModelRoutingEnabled: g.ModelRoutingEnabled,
|
||||
MCPXMLInject: g.MCPXMLInject,
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
||||
SupportedModelScopes: g.SupportedModelScopes,
|
||||
AccountCount: g.AccountCount,
|
||||
ActiveAccountCount: g.ActiveAccountCount,
|
||||
RateLimitedAccountCount: g.RateLimitedAccountCount,
|
||||
SortOrder: g.SortOrder,
|
||||
}
|
||||
if len(g.AccountGroups) > 0 {
|
||||
out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups))
|
||||
|
||||
@@ -51,6 +51,29 @@ type SystemSettings struct {
|
||||
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
|
||||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||||
|
||||
OIDCConnectEnabled bool `json:"oidc_connect_enabled"`
|
||||
OIDCConnectProviderName string `json:"oidc_connect_provider_name"`
|
||||
OIDCConnectClientID string `json:"oidc_connect_client_id"`
|
||||
OIDCConnectClientSecretConfigured bool `json:"oidc_connect_client_secret_configured"`
|
||||
OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"`
|
||||
OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"`
|
||||
OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"`
|
||||
OIDCConnectTokenURL string `json:"oidc_connect_token_url"`
|
||||
OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"`
|
||||
OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"`
|
||||
OIDCConnectScopes string `json:"oidc_connect_scopes"`
|
||||
OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"`
|
||||
OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"`
|
||||
OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"`
|
||||
OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"`
|
||||
OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"`
|
||||
OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"`
|
||||
OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"`
|
||||
OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"`
|
||||
OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"`
|
||||
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
||||
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
|
||||
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
@@ -132,6 +155,9 @@ type PublicSettings struct {
|
||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -112,7 +116,8 @@ type AdminGroup struct {
|
||||
MCPXMLInject bool `json:"mcp_xml_inject"`
|
||||
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||
|
||||
@@ -47,6 +47,13 @@ func resolveOpenAIForwardDefaultMappedModel(apiKey *service.APIKey, fallbackMode
|
||||
return strings.TrimSpace(apiKey.Group.DefaultMappedModel)
|
||||
}
|
||||
|
||||
func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel))
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
|
||||
func NewOpenAIGatewayHandler(
|
||||
gatewayService *service.OpenAIGatewayService,
|
||||
@@ -551,6 +558,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
reqModel := modelResult.String()
|
||||
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
|
||||
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
|
||||
reqStream := gjson.GetBytes(body, "stream").Bool()
|
||||
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
@@ -609,17 +617,20 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
effectiveMappedModel := preferredMappedModel
|
||||
|
||||
for {
|
||||
// 清除上一次迭代的降级模型标记,避免残留影响本次迭代
|
||||
c.Set("openai_messages_fallback_model", "")
|
||||
currentRoutingModel := routingModel
|
||||
if effectiveMappedModel != "" {
|
||||
currentRoutingModel = effectiveMappedModel
|
||||
}
|
||||
reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
"", // no previous_response_id
|
||||
sessionHash,
|
||||
routingModel,
|
||||
currentRoutingModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
)
|
||||
@@ -628,29 +639,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
zap.Error(err),
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
// 首次调度失败 + 有默认映射模型 → 用默认模型重试
|
||||
if len(failedAccountIDs) == 0 {
|
||||
defaultModel := ""
|
||||
if apiKey.Group != nil {
|
||||
defaultModel = apiKey.Group.DefaultMappedModel
|
||||
}
|
||||
if defaultModel != "" && defaultModel != routingModel {
|
||||
reqLog.Info("openai_messages.fallback_to_default_model",
|
||||
zap.String("default_mapped_model", defaultModel),
|
||||
)
|
||||
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
defaultModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
)
|
||||
if err == nil && selection != nil {
|
||||
c.Set("openai_messages_fallback_model", defaultModel)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
|
||||
return
|
||||
@@ -682,9 +671,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
|
||||
forwardStart := time.Now()
|
||||
|
||||
// Forward 层需要始终拿到 group 默认映射模型,这样未命中账号级映射的
|
||||
// Claude 兼容模型才不会在后续 Codex 规范化中意外退化到 gpt-5.1。
|
||||
defaultMappedModel := resolveOpenAIForwardDefaultMappedModel(apiKey, c.GetString("openai_messages_fallback_model"))
|
||||
defaultMappedModel := strings.TrimSpace(effectiveMappedModel)
|
||||
// 应用渠道模型映射到请求体
|
||||
forwardBody := body
|
||||
if channelMappingMsg.Mapped {
|
||||
|
||||
@@ -360,7 +360,7 @@ func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) {
|
||||
require.Equal(t, "gpt-5.2", resolveOpenAIForwardDefaultMappedModel(apiKey, " gpt-5.2 "))
|
||||
})
|
||||
|
||||
t.Run("uses_group_default_on_normal_path", func(t *testing.T) {
|
||||
t.Run("uses_group_default_when_explicit_fallback_absent", func(t *testing.T) {
|
||||
apiKey := &service.APIKey{
|
||||
Group: &service.Group{DefaultMappedModel: "gpt-5.4"},
|
||||
}
|
||||
@@ -376,6 +376,45 @@ func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
|
||||
t.Run("exact_claude_model_override_wins", func(t *testing.T) {
|
||||
apiKey := &service.APIKey{
|
||||
Group: &service.Group{
|
||||
MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{
|
||||
SonnetMappedModel: "gpt-5.2",
|
||||
ExactModelMappings: map[string]string{
|
||||
"claude-sonnet-4-5-20250929": "gpt-5.4-mini-high",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
|
||||
})
|
||||
|
||||
t.Run("uses_family_default_when_no_override", func(t *testing.T) {
|
||||
apiKey := &service.APIKey{Group: &service.Group{}}
|
||||
require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-opus-4-6"))
|
||||
require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
|
||||
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001"))
|
||||
})
|
||||
|
||||
t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) {
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929"))
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4"))
|
||||
})
|
||||
|
||||
t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) {
|
||||
apiKey := &service.APIKey{
|
||||
Group: &service.Group{
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
},
|
||||
}
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "gpt-5.4"))
|
||||
require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIResponses_MissingDependencies_ReturnsServiceUnavailable(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
Version: h.version,
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ type AnthropicRequest struct {
|
||||
|
||||
// AnthropicOutputConfig controls output generation parameters.
|
||||
type AnthropicOutputConfig struct {
|
||||
Effort string `json:"effort,omitempty"` // "low" | "medium" | "high"
|
||||
Effort string `json:"effort,omitempty"` // "low" | "medium" | "high" | "max"
|
||||
}
|
||||
|
||||
// AnthropicThinking configures extended thinking in the Anthropic API.
|
||||
@@ -167,7 +167,7 @@ type ResponsesRequest struct {
|
||||
|
||||
// ResponsesReasoning configures reasoning effort in the Responses API.
|
||||
type ResponsesReasoning struct {
|
||||
Effort string `json:"effort"` // "low" | "medium" | "high"
|
||||
Effort string `json:"effort"` // "low" | "medium" | "high" | "xhigh"
|
||||
Summary string `json:"summary,omitempty"` // "auto" | "concise" | "detailed"
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ type ChatCompletionsRequest struct {
|
||||
StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"`
|
||||
Tools []ChatTool `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high"
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh"
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Stop json.RawMessage `json:"stop,omitempty"` // string or []string
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
|
||||
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
|
||||
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
|
||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel)
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig)
|
||||
|
||||
// 设置模型路由配置
|
||||
if groupIn.ModelRouting != nil {
|
||||
@@ -127,7 +128,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
|
||||
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
|
||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel)
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||
SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig)
|
||||
|
||||
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
|
||||
if groupIn.DailyLimitUSD != nil {
|
||||
|
||||
@@ -378,6 +378,7 @@ func buildSchedulerMetadataAccount(account service.Account) service.Account {
|
||||
Platform: account.Platform,
|
||||
Type: account.Type,
|
||||
Concurrency: account.Concurrency,
|
||||
LoadFactor: account.LoadFactor,
|
||||
Priority: account.Priority,
|
||||
RateMultiplier: account.RateMultiplier,
|
||||
Status: account.Status,
|
||||
|
||||
@@ -462,6 +462,28 @@ func TestAPIContracts(t *testing.T) {
|
||||
service.SettingKeyTurnstileSiteKey: "site-key",
|
||||
service.SettingKeyTurnstileSecretKey: "secret-key",
|
||||
|
||||
service.SettingKeyOIDCConnectEnabled: "false",
|
||||
service.SettingKeyOIDCConnectProviderName: "OIDC",
|
||||
service.SettingKeyOIDCConnectClientID: "",
|
||||
service.SettingKeyOIDCConnectIssuerURL: "",
|
||||
service.SettingKeyOIDCConnectDiscoveryURL: "",
|
||||
service.SettingKeyOIDCConnectAuthorizeURL: "",
|
||||
service.SettingKeyOIDCConnectTokenURL: "",
|
||||
service.SettingKeyOIDCConnectUserInfoURL: "",
|
||||
service.SettingKeyOIDCConnectJWKSURL: "",
|
||||
service.SettingKeyOIDCConnectScopes: "openid email profile",
|
||||
service.SettingKeyOIDCConnectRedirectURL: "",
|
||||
service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
||||
service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
||||
service.SettingKeyOIDCConnectUsePKCE: "false",
|
||||
service.SettingKeyOIDCConnectValidateIDToken: "true",
|
||||
service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
|
||||
service.SettingKeyOIDCConnectClockSkewSeconds: "120",
|
||||
service.SettingKeyOIDCConnectRequireEmailVerified: "false",
|
||||
service.SettingKeyOIDCConnectUserInfoEmailPath: "",
|
||||
service.SettingKeyOIDCConnectUserInfoIDPath: "",
|
||||
service.SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
||||
|
||||
service.SettingKeySiteName: "Sub2API",
|
||||
service.SettingKeySiteLogo: "",
|
||||
service.SettingKeySiteSubtitle: "Subtitle",
|
||||
@@ -503,10 +525,32 @@ func TestAPIContracts(t *testing.T) {
|
||||
"turnstile_enabled": true,
|
||||
"turnstile_site_key": "site-key",
|
||||
"turnstile_secret_key_configured": true,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
"linuxdo_connect_redirect_url": "",
|
||||
"oidc_connect_enabled": false,
|
||||
"oidc_connect_provider_name": "OIDC",
|
||||
"oidc_connect_client_id": "",
|
||||
"oidc_connect_client_secret_configured": false,
|
||||
"oidc_connect_issuer_url": "",
|
||||
"oidc_connect_discovery_url": "",
|
||||
"oidc_connect_authorize_url": "",
|
||||
"oidc_connect_token_url": "",
|
||||
"oidc_connect_userinfo_url": "",
|
||||
"oidc_connect_jwks_url": "",
|
||||
"oidc_connect_scopes": "openid email profile",
|
||||
"oidc_connect_redirect_url": "",
|
||||
"oidc_connect_frontend_redirect_url": "/auth/oidc/callback",
|
||||
"oidc_connect_token_auth_method": "client_secret_post",
|
||||
"oidc_connect_use_pkce": false,
|
||||
"oidc_connect_validate_id_token": true,
|
||||
"oidc_connect_allowed_signing_algs": "RS256,ES256,PS256",
|
||||
"oidc_connect_clock_skew_seconds": 120,
|
||||
"oidc_connect_require_email_verified": false,
|
||||
"oidc_connect_userinfo_email_path": "",
|
||||
"oidc_connect_userinfo_id_path": "",
|
||||
"oidc_connect_userinfo_username_path": "",
|
||||
"ops_monitoring_enabled": false,
|
||||
"ops_realtime_monitoring_enabled": true,
|
||||
"ops_query_mode_default": "auto",
|
||||
|
||||
@@ -70,6 +70,14 @@ func RegisterAuthRoutes(
|
||||
}),
|
||||
h.Auth.CompleteLinuxDoOAuthRegistration,
|
||||
)
|
||||
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
||||
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
|
||||
auth.POST("/oauth/oidc/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CompleteOIDCOAuthRegistration,
|
||||
)
|
||||
}
|
||||
|
||||
// 公开设置(无需认证)
|
||||
|
||||
@@ -152,10 +152,11 @@ type CreateGroupInput struct {
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes []string
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool
|
||||
DefaultMappedModel string
|
||||
RequireOAuthOnly bool
|
||||
RequirePrivacySet bool
|
||||
AllowMessagesDispatch bool
|
||||
DefaultMappedModel string
|
||||
RequireOAuthOnly bool
|
||||
RequirePrivacySet bool
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||||
// 从指定分组复制账号(创建分组后在同一事务内绑定)
|
||||
CopyAccountsFromGroupIDs []int64
|
||||
}
|
||||
@@ -186,10 +187,11 @@ type UpdateGroupInput struct {
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes *[]string
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool
|
||||
DefaultMappedModel *string
|
||||
RequireOAuthOnly *bool
|
||||
RequirePrivacySet *bool
|
||||
AllowMessagesDispatch *bool
|
||||
DefaultMappedModel *string
|
||||
RequireOAuthOnly *bool
|
||||
RequirePrivacySet *bool
|
||||
MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
CopyAccountsFromGroupIDs []int64
|
||||
}
|
||||
@@ -908,7 +910,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
RequireOAuthOnly: input.RequireOAuthOnly,
|
||||
RequirePrivacySet: input.RequirePrivacySet,
|
||||
DefaultMappedModel: input.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig),
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
if err := s.groupRepo.Create(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1135,6 +1139,10 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
if input.DefaultMappedModel != nil {
|
||||
group.DefaultMappedModel = *input.DefaultMappedModel
|
||||
}
|
||||
if input.MessagesDispatchModelConfig != nil {
|
||||
group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig)
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
|
||||
if err := s.groupRepo.Update(ctx, group); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func ptrString[T ~string](v T) *string {
|
||||
s := string(v)
|
||||
return &s
|
||||
}
|
||||
|
||||
// groupRepoStubForAdmin 用于测试 AdminService 的 GroupRepository Stub
|
||||
type groupRepoStubForAdmin struct {
|
||||
created *Group // 记录 Create 调用的参数
|
||||
@@ -261,6 +266,116 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) {
|
||||
require.Nil(t, repo.updated.ImagePrice4K)
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_NormalizesMessagesDispatchModelConfig(t *testing.T) {
|
||||
repo := &groupRepoStubForAdmin{}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "dispatch-group",
|
||||
Description: "dispatch config",
|
||||
Platform: PlatformOpenAI,
|
||||
RateMultiplier: 1.0,
|
||||
MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: " gpt-5.4-high ",
|
||||
SonnetMappedModel: " gpt-5.3-codex ",
|
||||
HaikuMappedModel: " gpt-5.4-mini-medium ",
|
||||
ExactModelMappings: map[string]string{
|
||||
" claude-sonnet-4-5-20250929 ": " gpt-5.2-high ",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.NotNil(t, repo.created)
|
||||
require.Equal(t, OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: "gpt-5.4",
|
||||
SonnetMappedModel: "gpt-5.3-codex",
|
||||
HaikuMappedModel: "gpt-5.4-mini",
|
||||
ExactModelMappings: map[string]string{
|
||||
"claude-sonnet-4-5-20250929": "gpt-5.2",
|
||||
},
|
||||
}, repo.created.MessagesDispatchModelConfig)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_NormalizesMessagesDispatchModelConfig(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1,
|
||||
Name: "existing-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
|
||||
MessagesDispatchModelConfig: &OpenAIMessagesDispatchModelConfig{
|
||||
SonnetMappedModel: " gpt-5.4-medium ",
|
||||
ExactModelMappings: map[string]string{
|
||||
" claude-haiku-4-5-20251001 ": " gpt-5.4-mini-high ",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.NotNil(t, repo.updated)
|
||||
require.Equal(t, OpenAIMessagesDispatchModelConfig{
|
||||
SonnetMappedModel: "gpt-5.4",
|
||||
ExactModelMappings: map[string]string{
|
||||
"claude-haiku-4-5-20251001": "gpt-5.4-mini",
|
||||
},
|
||||
}, repo.updated.MessagesDispatchModelConfig)
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatform(t *testing.T) {
|
||||
repo := &groupRepoStubForAdmin{}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "anthropic-group",
|
||||
Description: "non-openai",
|
||||
Platform: PlatformAnthropic,
|
||||
RateMultiplier: 1.0,
|
||||
AllowMessagesDispatch: true,
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: "gpt-5.4",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.NotNil(t, repo.created)
|
||||
require.False(t, repo.created.AllowMessagesDispatch)
|
||||
require.Empty(t, repo.created.DefaultMappedModel)
|
||||
require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.created.MessagesDispatchModelConfig)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChangesAwayFromOpenAI(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1,
|
||||
Name: "existing-openai-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
AllowMessagesDispatch: true,
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
|
||||
SonnetMappedModel: "gpt-5.3-codex",
|
||||
},
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
|
||||
Platform: PlatformAnthropic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.NotNil(t, repo.updated)
|
||||
require.Equal(t, PlatformAnthropic, repo.updated.Platform)
|
||||
require.False(t, repo.updated.AllowMessagesDispatch)
|
||||
require.Empty(t, repo.updated.DefaultMappedModel)
|
||||
require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.updated.MessagesDispatchModelConfig)
|
||||
}
|
||||
|
||||
func TestAdminService_ListGroups_WithSearch(t *testing.T) {
|
||||
// 测试:
|
||||
// 1. search 参数正常传递到 repository 层
|
||||
|
||||
@@ -833,7 +833,8 @@ func randomHexString(byteLength int) (string, error) {
|
||||
|
||||
func isReservedEmail(email string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain)
|
||||
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) ||
|
||||
strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain)
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT access token
|
||||
|
||||
@@ -71,6 +71,9 @@ const (
|
||||
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
|
||||
const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
||||
|
||||
// OIDCConnectSyntheticEmailDomain 是 OIDC 用户的合成邮箱后缀(RFC 保留域名)。
|
||||
const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid"
|
||||
|
||||
// Setting keys
|
||||
const (
|
||||
// 注册设置
|
||||
@@ -105,6 +108,30 @@ const (
|
||||
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
|
||||
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
||||
|
||||
// Generic OIDC OAuth 登录设置
|
||||
SettingKeyOIDCConnectEnabled = "oidc_connect_enabled"
|
||||
SettingKeyOIDCConnectProviderName = "oidc_connect_provider_name"
|
||||
SettingKeyOIDCConnectClientID = "oidc_connect_client_id"
|
||||
SettingKeyOIDCConnectClientSecret = "oidc_connect_client_secret"
|
||||
SettingKeyOIDCConnectIssuerURL = "oidc_connect_issuer_url"
|
||||
SettingKeyOIDCConnectDiscoveryURL = "oidc_connect_discovery_url"
|
||||
SettingKeyOIDCConnectAuthorizeURL = "oidc_connect_authorize_url"
|
||||
SettingKeyOIDCConnectTokenURL = "oidc_connect_token_url"
|
||||
SettingKeyOIDCConnectUserInfoURL = "oidc_connect_userinfo_url"
|
||||
SettingKeyOIDCConnectJWKSURL = "oidc_connect_jwks_url"
|
||||
SettingKeyOIDCConnectScopes = "oidc_connect_scopes"
|
||||
SettingKeyOIDCConnectRedirectURL = "oidc_connect_redirect_url"
|
||||
SettingKeyOIDCConnectFrontendRedirectURL = "oidc_connect_frontend_redirect_url"
|
||||
SettingKeyOIDCConnectTokenAuthMethod = "oidc_connect_token_auth_method"
|
||||
SettingKeyOIDCConnectUsePKCE = "oidc_connect_use_pkce"
|
||||
SettingKeyOIDCConnectValidateIDToken = "oidc_connect_validate_id_token"
|
||||
SettingKeyOIDCConnectAllowedSigningAlgs = "oidc_connect_allowed_signing_algs"
|
||||
SettingKeyOIDCConnectClockSkewSeconds = "oidc_connect_clock_skew_seconds"
|
||||
SettingKeyOIDCConnectRequireEmailVerified = "oidc_connect_require_email_verified"
|
||||
SettingKeyOIDCConnectUserInfoEmailPath = "oidc_connect_userinfo_email_path"
|
||||
SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path"
|
||||
SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path"
|
||||
|
||||
// OEM设置
|
||||
SettingKeySiteName = "site_name" // 网站名称
|
||||
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
||||
|
||||
@@ -3,8 +3,12 @@ package service
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig
|
||||
|
||||
type Group struct {
|
||||
ID int64
|
||||
Name string
|
||||
@@ -49,10 +53,11 @@ type Group struct {
|
||||
SortOrder int
|
||||
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool
|
||||
RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
DefaultMappedModel string
|
||||
AllowMessagesDispatch bool
|
||||
RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
DefaultMappedModel string
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
type forcedCodexInstructionsTemplateData struct {
|
||||
ExistingInstructions string
|
||||
OriginalModel string
|
||||
NormalizedModel string
|
||||
BillingModel string
|
||||
UpstreamModel string
|
||||
}
|
||||
|
||||
func applyForcedCodexInstructionsTemplate(
|
||||
reqBody map[string]any,
|
||||
templateText string,
|
||||
data forcedCodexInstructionsTemplateData,
|
||||
) (bool, error) {
|
||||
rendered, err := renderForcedCodexInstructionsTemplate(templateText, data)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rendered == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
existing, _ := reqBody["instructions"].(string)
|
||||
if strings.TrimSpace(existing) == rendered {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
reqBody["instructions"] = rendered
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func renderForcedCodexInstructionsTemplate(
|
||||
templateText string,
|
||||
data forcedCodexInstructionsTemplateData,
|
||||
) (string, error) {
|
||||
tmpl, err := template.New("forced_codex_instructions").Option("missingkey=zero").Parse(templateText)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse forced codex instructions template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("render forced codex instructions template: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -127,3 +130,101 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T
|
||||
t.Logf("upstream body: %s", string(upstream.lastBody))
|
||||
t.Logf("response body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
templateDir := t.TempDir()
|
||||
templatePath := filepath.Join(templateDir, "codex-instructions.md.tmpl")
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{
|
||||
ForcedCodexInstructionsTemplateFile: templatePath,
|
||||
ForcedCodexInstructionsTemplate: "server-prefix\n\n{{ .ExistingInstructions }}",
|
||||
}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "server-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced_cached"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{
|
||||
ForcedCodexInstructionsTemplateFile: "/path/that/should/not/be/read.tmpl",
|
||||
ForcedCodexInstructionsTemplate: "cached-prefix\n\n{{ .ExistingInstructions }}",
|
||||
}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "cached-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
|
||||
}
|
||||
|
||||
@@ -86,6 +86,24 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
return nil, fmt.Errorf("unmarshal for codex transform: %w", err)
|
||||
}
|
||||
codexResult := applyCodexOAuthTransform(reqBody, false, false)
|
||||
forcedTemplateText := ""
|
||||
if s.cfg != nil {
|
||||
forcedTemplateText = s.cfg.Gateway.ForcedCodexInstructionsTemplate
|
||||
}
|
||||
templateUpstreamModel := upstreamModel
|
||||
if codexResult.NormalizedModel != "" {
|
||||
templateUpstreamModel = codexResult.NormalizedModel
|
||||
}
|
||||
existingInstructions, _ := reqBody["instructions"].(string)
|
||||
if _, err := applyForcedCodexInstructionsTemplate(reqBody, forcedTemplateText, forcedCodexInstructionsTemplateData{
|
||||
ExistingInstructions: strings.TrimSpace(existingInstructions),
|
||||
OriginalModel: originalModel,
|
||||
NormalizedModel: normalizedModel,
|
||||
BillingModel: billingModel,
|
||||
UpstreamModel: templateUpstreamModel,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codexResult.NormalizedModel != "" {
|
||||
upstreamModel = codexResult.NormalizedModel
|
||||
}
|
||||
|
||||
100
backend/internal/service/openai_messages_dispatch.go
Normal file
100
backend/internal/service/openai_messages_dispatch.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
defaultOpenAIMessagesDispatchOpusMappedModel = "gpt-5.4"
|
||||
defaultOpenAIMessagesDispatchSonnetMappedModel = "gpt-5.3-codex"
|
||||
defaultOpenAIMessagesDispatchHaikuMappedModel = "gpt-5.4-mini"
|
||||
)
|
||||
|
||||
func normalizeOpenAIMessagesDispatchMappedModel(model string) string {
|
||||
model = NormalizeOpenAICompatRequestedModel(strings.TrimSpace(model))
|
||||
return strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
func normalizeOpenAIMessagesDispatchModelConfig(cfg OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig {
|
||||
out := OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: normalizeOpenAIMessagesDispatchMappedModel(cfg.OpusMappedModel),
|
||||
SonnetMappedModel: normalizeOpenAIMessagesDispatchMappedModel(cfg.SonnetMappedModel),
|
||||
HaikuMappedModel: normalizeOpenAIMessagesDispatchMappedModel(cfg.HaikuMappedModel),
|
||||
}
|
||||
|
||||
if len(cfg.ExactModelMappings) > 0 {
|
||||
out.ExactModelMappings = make(map[string]string, len(cfg.ExactModelMappings))
|
||||
for requestedModel, mappedModel := range cfg.ExactModelMappings {
|
||||
requestedModel = strings.TrimSpace(requestedModel)
|
||||
mappedModel = normalizeOpenAIMessagesDispatchMappedModel(mappedModel)
|
||||
if requestedModel == "" || mappedModel == "" {
|
||||
continue
|
||||
}
|
||||
out.ExactModelMappings[requestedModel] = mappedModel
|
||||
}
|
||||
if len(out.ExactModelMappings) == 0 {
|
||||
out.ExactModelMappings = nil
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func claudeMessagesDispatchFamily(model string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(model))
|
||||
if !strings.HasPrefix(normalized, "claude") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(normalized, "opus"):
|
||||
return "opus"
|
||||
case strings.Contains(normalized, "sonnet"):
|
||||
return "sonnet"
|
||||
case strings.Contains(normalized, "haiku"):
|
||||
return "haiku"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
requestedModel = strings.TrimSpace(requestedModel)
|
||||
if requestedModel == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
cfg := normalizeOpenAIMessagesDispatchModelConfig(g.MessagesDispatchModelConfig)
|
||||
if mappedModel := strings.TrimSpace(cfg.ExactModelMappings[requestedModel]); mappedModel != "" {
|
||||
return mappedModel
|
||||
}
|
||||
|
||||
switch claudeMessagesDispatchFamily(requestedModel) {
|
||||
case "opus":
|
||||
if mappedModel := strings.TrimSpace(cfg.OpusMappedModel); mappedModel != "" {
|
||||
return mappedModel
|
||||
}
|
||||
return defaultOpenAIMessagesDispatchOpusMappedModel
|
||||
case "sonnet":
|
||||
if mappedModel := strings.TrimSpace(cfg.SonnetMappedModel); mappedModel != "" {
|
||||
return mappedModel
|
||||
}
|
||||
return defaultOpenAIMessagesDispatchSonnetMappedModel
|
||||
case "haiku":
|
||||
if mappedModel := strings.TrimSpace(cfg.HaikuMappedModel); mappedModel != "" {
|
||||
return mappedModel
|
||||
}
|
||||
return defaultOpenAIMessagesDispatchHaikuMappedModel
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeGroupMessagesDispatchFields(g *Group) {
|
||||
if g == nil || g.Platform == PlatformOpenAI {
|
||||
return
|
||||
}
|
||||
g.AllowMessagesDispatch = false
|
||||
g.DefaultMappedModel = ""
|
||||
g.MessagesDispatchModelConfig = OpenAIMessagesDispatchModelConfig{}
|
||||
}
|
||||
27
backend/internal/service/openai_messages_dispatch_test.go
Normal file
27
backend/internal/service/openai_messages_dispatch_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
import "github.com/stretchr/testify/require"
|
||||
|
||||
func TestNormalizeOpenAIMessagesDispatchModelConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := normalizeOpenAIMessagesDispatchModelConfig(OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: " gpt-5.4-high ",
|
||||
SonnetMappedModel: "gpt-5.3-codex",
|
||||
HaikuMappedModel: " gpt-5.4-mini-medium ",
|
||||
ExactModelMappings: map[string]string{
|
||||
" claude-sonnet-4-5-20250929 ": " gpt-5.2-high ",
|
||||
"": "gpt-5.4",
|
||||
"claude-opus-4-6": " ",
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, "gpt-5.4", cfg.OpusMappedModel)
|
||||
require.Equal(t, "gpt-5.3-codex", cfg.SonnetMappedModel)
|
||||
require.Equal(t, "gpt-5.4-mini", cfg.HaikuMappedModel)
|
||||
require.Equal(t, map[string]string{
|
||||
"claude-sonnet-4-5-20250929": "gpt-5.2",
|
||||
}, cfg.ExactModelMappings)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
var ErrOpsDisabled = infraerrors.NotFound("OPS_DISABLED", "Ops monitoring is disabled")
|
||||
|
||||
const (
|
||||
opsMaxStoredRequestBodyBytes = 10 * 1024
|
||||
opsMaxStoredRequestBodyBytes = 256 * 1024
|
||||
opsMaxStoredErrorBodyBytes = 20 * 1024
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/imroc/req/v3"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
@@ -167,6 +168,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyCustomEndpoints,
|
||||
SettingKeyLinuxDoConnectEnabled,
|
||||
SettingKeyBackendModeEnabled,
|
||||
SettingKeyOIDCConnectEnabled,
|
||||
SettingKeyOIDCConnectProviderName,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
@@ -180,6 +183,19 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
} else {
|
||||
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
||||
}
|
||||
oidcEnabled := false
|
||||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||||
oidcEnabled = raw == "true"
|
||||
} else {
|
||||
oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled
|
||||
}
|
||||
oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName])
|
||||
if oidcProviderName == "" && s.cfg != nil {
|
||||
oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName)
|
||||
}
|
||||
if oidcProviderName == "" {
|
||||
oidcProviderName = "OIDC"
|
||||
}
|
||||
|
||||
// Password reset requires email verification to be enabled
|
||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||
@@ -218,6 +234,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
OIDCOAuthEnabled: oidcEnabled,
|
||||
OIDCOAuthProviderName: oidcProviderName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -267,6 +285,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
@@ -294,6 +314,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||
Version: s.version,
|
||||
}, nil
|
||||
}
|
||||
@@ -346,8 +368,8 @@ func safeRawJSONArray(raw string) json.RawMessage {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url
|
||||
// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
|
||||
// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL,
|
||||
// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
|
||||
func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
|
||||
settings, err := s.GetPublicSettings(ctx)
|
||||
if err != nil {
|
||||
@@ -366,6 +388,9 @@ func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, erro
|
||||
}
|
||||
}
|
||||
|
||||
// home content URL (when home_content is set to a URL for iframe embedding)
|
||||
addOrigin(settings.HomeContent)
|
||||
|
||||
// purchase subscription URL
|
||||
if settings.PurchaseSubscriptionEnabled {
|
||||
addOrigin(settings.PurchaseSubscriptionURL)
|
||||
@@ -473,6 +498,32 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
||||
}
|
||||
|
||||
// Generic OIDC OAuth 登录
|
||||
updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled)
|
||||
updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName
|
||||
updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID
|
||||
updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL
|
||||
updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL
|
||||
updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL
|
||||
updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL
|
||||
updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL
|
||||
updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL
|
||||
updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes
|
||||
updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL
|
||||
updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL
|
||||
updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod
|
||||
updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE)
|
||||
updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken)
|
||||
updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs
|
||||
updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds)
|
||||
updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified)
|
||||
updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath
|
||||
updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath
|
||||
updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath
|
||||
if settings.OIDCConnectClientSecret != "" {
|
||||
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
|
||||
}
|
||||
|
||||
// OEM设置
|
||||
updates[SettingKeySiteName] = settings.SiteName
|
||||
updates[SettingKeySiteLogo] = settings.SiteLogo
|
||||
@@ -851,6 +902,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
||||
SettingKeyCustomMenuItems: "[]",
|
||||
SettingKeyCustomEndpoints: "[]",
|
||||
SettingKeyOIDCConnectEnabled: "false",
|
||||
SettingKeyOIDCConnectProviderName: "OIDC",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
@@ -980,6 +1033,138 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
}
|
||||
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
|
||||
|
||||
// Generic OIDC 设置:
|
||||
// - 兼容 config.yaml/env
|
||||
// - 支持后台系统设置覆盖并持久化(存储于 DB)
|
||||
oidcBase := config.OIDCConnectConfig{}
|
||||
if s.cfg != nil {
|
||||
oidcBase = s.cfg.OIDC
|
||||
}
|
||||
|
||||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||||
result.OIDCConnectEnabled = raw == "true"
|
||||
} else {
|
||||
result.OIDCConnectEnabled = oidcBase.Enabled
|
||||
}
|
||||
|
||||
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectProviderName = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName)
|
||||
}
|
||||
if result.OIDCConnectProviderName == "" {
|
||||
result.OIDCConnectProviderName = "OIDC"
|
||||
}
|
||||
|
||||
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectClientID = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectIssuerURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectDiscoveryURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectAuthorizeURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectTokenURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectUserInfoURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectJWKSURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectScopes = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectRedirectURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
|
||||
} else {
|
||||
result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod))
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
|
||||
result.OIDCConnectUsePKCE = raw == "true"
|
||||
} else {
|
||||
result.OIDCConnectUsePKCE = oidcBase.UsePKCE
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||||
result.OIDCConnectValidateIDToken = raw == "true"
|
||||
} else {
|
||||
result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||||
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs)
|
||||
}
|
||||
clockSkewSet := false
|
||||
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
|
||||
result.OIDCConnectClockSkewSeconds = parsed
|
||||
clockSkewSet = true
|
||||
}
|
||||
}
|
||||
if !clockSkewSet {
|
||||
result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds
|
||||
}
|
||||
if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 {
|
||||
result.OIDCConnectClockSkewSeconds = 120
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
|
||||
result.OIDCConnectRequireEmailVerified = raw == "true"
|
||||
} else {
|
||||
result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
|
||||
result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
|
||||
result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
|
||||
result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v)
|
||||
} else {
|
||||
result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath)
|
||||
}
|
||||
result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret])
|
||||
if result.OIDCConnectClientSecret == "" {
|
||||
result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret)
|
||||
}
|
||||
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
|
||||
|
||||
// Model fallback settings
|
||||
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
|
||||
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
|
||||
@@ -1396,6 +1581,282 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin
|
||||
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
|
||||
}
|
||||
|
||||
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
|
||||
//
|
||||
// 优先级:
|
||||
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
||||
// - 否则回退到 config.yaml/env 的值
|
||||
func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
|
||||
if s == nil || s.cfg == nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||||
}
|
||||
|
||||
effective := s.cfg.OIDC
|
||||
|
||||
keys := []string{
|
||||
SettingKeyOIDCConnectEnabled,
|
||||
SettingKeyOIDCConnectProviderName,
|
||||
SettingKeyOIDCConnectClientID,
|
||||
SettingKeyOIDCConnectClientSecret,
|
||||
SettingKeyOIDCConnectIssuerURL,
|
||||
SettingKeyOIDCConnectDiscoveryURL,
|
||||
SettingKeyOIDCConnectAuthorizeURL,
|
||||
SettingKeyOIDCConnectTokenURL,
|
||||
SettingKeyOIDCConnectUserInfoURL,
|
||||
SettingKeyOIDCConnectJWKSURL,
|
||||
SettingKeyOIDCConnectScopes,
|
||||
SettingKeyOIDCConnectRedirectURL,
|
||||
SettingKeyOIDCConnectFrontendRedirectURL,
|
||||
SettingKeyOIDCConnectTokenAuthMethod,
|
||||
SettingKeyOIDCConnectUsePKCE,
|
||||
SettingKeyOIDCConnectValidateIDToken,
|
||||
SettingKeyOIDCConnectAllowedSigningAlgs,
|
||||
SettingKeyOIDCConnectClockSkewSeconds,
|
||||
SettingKeyOIDCConnectRequireEmailVerified,
|
||||
SettingKeyOIDCConnectUserInfoEmailPath,
|
||||
SettingKeyOIDCConnectUserInfoIDPath,
|
||||
SettingKeyOIDCConnectUserInfoUsernamePath,
|
||||
}
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err)
|
||||
}
|
||||
|
||||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||||
effective.Enabled = raw == "true"
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.ProviderName = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.ClientID = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.ClientSecret = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.IssuerURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.DiscoveryURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.AuthorizeURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.TokenURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.UserInfoURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.JWKSURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.Scopes = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.RedirectURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.FrontendRedirectURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
|
||||
effective.UsePKCE = raw == "true"
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||||
effective.ValidateIDToken = raw == "true"
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||||
effective.AllowedSigningAlgs = strings.TrimSpace(v)
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
|
||||
if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil {
|
||||
effective.ClockSkewSeconds = parsed
|
||||
}
|
||||
}
|
||||
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
|
||||
effective.RequireEmailVerified = raw == "true"
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
|
||||
effective.UserInfoEmailPath = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
|
||||
effective.UserInfoIDPath = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
|
||||
effective.UserInfoUsernamePath = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
if !effective.Enabled {
|
||||
return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||||
}
|
||||
if strings.TrimSpace(effective.ProviderName) == "" {
|
||||
effective.ProviderName = "OIDC"
|
||||
}
|
||||
if strings.TrimSpace(effective.ClientID) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
||||
}
|
||||
if strings.TrimSpace(effective.IssuerURL) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured")
|
||||
}
|
||||
if strings.TrimSpace(effective.RedirectURL) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
|
||||
}
|
||||
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
|
||||
}
|
||||
if !scopesContainOpenID(effective.Scopes) {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid")
|
||||
}
|
||||
if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600")
|
||||
}
|
||||
|
||||
if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid")
|
||||
}
|
||||
|
||||
discoveryURL := strings.TrimSpace(effective.DiscoveryURL)
|
||||
if discoveryURL == "" {
|
||||
discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL)
|
||||
effective.DiscoveryURL = discoveryURL
|
||||
}
|
||||
if discoveryURL != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid")
|
||||
}
|
||||
}
|
||||
|
||||
needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" ||
|
||||
strings.TrimSpace(effective.TokenURL) == "" ||
|
||||
(effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "")
|
||||
if needsDiscovery && discoveryURL != "" {
|
||||
metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL)
|
||||
if resolveErr != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr)
|
||||
}
|
||||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||||
effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint)
|
||||
}
|
||||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||||
effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint)
|
||||
}
|
||||
if strings.TrimSpace(effective.UserInfoURL) == "" {
|
||||
effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint)
|
||||
}
|
||||
if strings.TrimSpace(effective.JWKSURL) == "" {
|
||||
effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(effective.AuthorizeURL) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
|
||||
}
|
||||
if strings.TrimSpace(effective.TokenURL) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
|
||||
}
|
||||
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
|
||||
}
|
||||
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
|
||||
}
|
||||
if v := strings.TrimSpace(effective.UserInfoURL); v != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
|
||||
}
|
||||
}
|
||||
if effective.ValidateIDToken {
|
||||
if strings.TrimSpace(effective.JWKSURL) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured")
|
||||
}
|
||||
if strings.TrimSpace(effective.AllowedSigningAlgs) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured")
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(effective.JWKSURL); v != "" {
|
||||
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid")
|
||||
}
|
||||
}
|
||||
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
|
||||
}
|
||||
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
||||
}
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
||||
switch method {
|
||||
case "", "client_secret_post", "client_secret_basic":
|
||||
if strings.TrimSpace(effective.ClientSecret) == "" {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||||
}
|
||||
case "none":
|
||||
if !effective.UsePKCE {
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none")
|
||||
}
|
||||
default:
|
||||
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
|
||||
}
|
||||
|
||||
return effective, nil
|
||||
}
|
||||
|
||||
func scopesContainOpenID(scopes string) bool {
|
||||
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
|
||||
if scope == "openid" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type oidcProviderMetadata struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
|
||||
func oidcDefaultDiscoveryURL(issuerURL string) string {
|
||||
issuerURL = strings.TrimSpace(issuerURL)
|
||||
if issuerURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
|
||||
}
|
||||
|
||||
func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) {
|
||||
discoveryURL = strings.TrimSpace(discoveryURL)
|
||||
if discoveryURL == "" {
|
||||
return nil, fmt.Errorf("discovery url is empty")
|
||||
}
|
||||
|
||||
resp, err := req.C().
|
||||
SetTimeout(15*time.Second).
|
||||
R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json").
|
||||
Get(discoveryURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request discovery document: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
metadata := &oidcProviderMetadata{}
|
||||
if err := json.Unmarshal(resp.Bytes(), metadata); err != nil {
|
||||
return nil, fmt.Errorf("parse discovery document: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// GetStreamTimeoutSettings 获取流超时处理配置
|
||||
func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings)
|
||||
|
||||
103
backend/internal/service/setting_service_oidc_config_test.go
Normal file
103
backend/internal/service/setting_service_oidc_config_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type settingOIDCRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
panic("unexpected Get call")
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
panic("unexpected GetValue call")
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
panic("unexpected Set call")
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := s.values[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||
panic("unexpected SetMultiple call")
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
panic("unexpected GetAll call")
|
||||
}
|
||||
|
||||
func (s *settingOIDCRepoStub) Delete(ctx context.Context, key string) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery(t *testing.T) {
|
||||
var discoveryHits int
|
||||
var baseURL string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/issuer/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
discoveryHits++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{
|
||||
"authorization_endpoint":"%s/issuer/protocol/openid-connect/auth",
|
||||
"token_endpoint":"%s/issuer/protocol/openid-connect/token",
|
||||
"userinfo_endpoint":"%s/issuer/protocol/openid-connect/userinfo",
|
||||
"jwks_uri":"%s/issuer/protocol/openid-connect/certs"
|
||||
}`, baseURL, baseURL, baseURL, baseURL)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
baseURL = srv.URL
|
||||
|
||||
cfg := &config.Config{
|
||||
OIDC: config.OIDCConnectConfig{
|
||||
Enabled: true,
|
||||
ProviderName: "OIDC",
|
||||
ClientID: "oidc-client",
|
||||
ClientSecret: "oidc-secret",
|
||||
IssuerURL: srv.URL + "/issuer",
|
||||
RedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback",
|
||||
FrontendRedirectURL: "/auth/oidc/callback",
|
||||
Scopes: "openid email profile",
|
||||
TokenAuthMethod: "client_secret_post",
|
||||
ValidateIDToken: true,
|
||||
AllowedSigningAlgs: "RS256",
|
||||
ClockSkewSeconds: 120,
|
||||
},
|
||||
}
|
||||
|
||||
repo := &settingOIDCRepoStub{values: map[string]string{}}
|
||||
svc := NewSettingService(repo, cfg)
|
||||
|
||||
got, err := svc.GetOIDCConnectOAuthConfig(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, discoveryHits)
|
||||
require.Equal(t, srv.URL+"/issuer/.well-known/openid-configuration", got.DiscoveryURL)
|
||||
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/auth", got.AuthorizeURL)
|
||||
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/token", got.TokenURL)
|
||||
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/userinfo", got.UserInfoURL)
|
||||
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/certs", got.JWKSURL)
|
||||
}
|
||||
@@ -31,6 +31,31 @@ type SystemSettings struct {
|
||||
LinuxDoConnectClientSecretConfigured bool
|
||||
LinuxDoConnectRedirectURL string
|
||||
|
||||
// Generic OIDC OAuth 登录
|
||||
OIDCConnectEnabled bool
|
||||
OIDCConnectProviderName string
|
||||
OIDCConnectClientID string
|
||||
OIDCConnectClientSecret string
|
||||
OIDCConnectClientSecretConfigured bool
|
||||
OIDCConnectIssuerURL string
|
||||
OIDCConnectDiscoveryURL string
|
||||
OIDCConnectAuthorizeURL string
|
||||
OIDCConnectTokenURL string
|
||||
OIDCConnectUserInfoURL string
|
||||
OIDCConnectJWKSURL string
|
||||
OIDCConnectScopes string
|
||||
OIDCConnectRedirectURL string
|
||||
OIDCConnectFrontendRedirectURL string
|
||||
OIDCConnectTokenAuthMethod string
|
||||
OIDCConnectUsePKCE bool
|
||||
OIDCConnectValidateIDToken bool
|
||||
OIDCConnectAllowedSigningAlgs string
|
||||
OIDCConnectClockSkewSeconds int
|
||||
OIDCConnectRequireEmailVerified bool
|
||||
OIDCConnectUserInfoEmailPath string
|
||||
OIDCConnectUserInfoIDPath string
|
||||
OIDCConnectUserInfoUsernamePath string
|
||||
|
||||
SiteName string
|
||||
SiteLogo string
|
||||
SiteSubtitle string
|
||||
@@ -114,9 +139,11 @@ type PublicSettings struct {
|
||||
CustomMenuItems string // JSON array of custom menu items
|
||||
CustomEndpoints string // JSON array of custom endpoints
|
||||
|
||||
LinuxDoOAuthEnabled bool
|
||||
BackendModeEnabled bool
|
||||
Version string
|
||||
LinuxDoOAuthEnabled bool
|
||||
BackendModeEnabled bool
|
||||
OIDCOAuthEnabled bool
|
||||
OIDCOAuthProviderName string
|
||||
Version string
|
||||
}
|
||||
|
||||
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN IF NOT EXISTS messages_dispatch_model_config JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
5
deploy/codex-instructions.md.tmpl
Normal file
5
deploy/codex-instructions.md.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
|
||||
|
||||
{{ if .ExistingInstructions }}
|
||||
{{ .ExistingInstructions }}
|
||||
{{ end }}
|
||||
@@ -202,6 +202,32 @@ gateway:
|
||||
#
|
||||
# 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。
|
||||
force_codex_cli: false
|
||||
# Optional: template file used to build the final top-level Codex `instructions`.
|
||||
# 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。
|
||||
#
|
||||
# This is applied on the `/v1/messages -> Responses/Codex` conversion path,
|
||||
# after Claude `system` has already been normalized into Codex `instructions`.
|
||||
# 该模板作用于 `/v1/messages -> Responses/Codex` 转换链路,且发生在 Claude `system`
|
||||
# 已经被归一化为 Codex `instructions` 之后。
|
||||
#
|
||||
# The template can reference:
|
||||
# 模板可引用:
|
||||
# - {{ .ExistingInstructions }} : converted client instructions/system
|
||||
# - {{ .OriginalModel }} : original requested model
|
||||
# - {{ .NormalizedModel }} : normalized routing model
|
||||
# - {{ .BillingModel }} : billing model
|
||||
# - {{ .UpstreamModel }} : final upstream model
|
||||
#
|
||||
# If you want to preserve client system prompts, keep {{ .ExistingInstructions }}
|
||||
# somewhere in the template. If omitted, the template output fully replaces it.
|
||||
# 如需保留客户端 system 提示词,请在模板中显式包含 {{ .ExistingInstructions }}。
|
||||
# 若省略,则模板输出会完全覆盖它。
|
||||
#
|
||||
# Docker users can mount a host file to /app/data/codex-instructions.md.tmpl
|
||||
# and point this field there.
|
||||
# Docker 用户可将宿主机文件挂载到 /app/data/codex-instructions.md.tmpl,
|
||||
# 然后把本字段指向该路径。
|
||||
forced_codex_instructions_template_file: ""
|
||||
# OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout)
|
||||
# 默认 false:过滤超时头,降低上游提前断流风险。
|
||||
openai_passthrough_allow_timeout_headers: false
|
||||
@@ -347,12 +373,6 @@ gateway:
|
||||
# Enable batch load calculation for scheduling
|
||||
# 启用调度批量负载计算
|
||||
load_batch_enabled: true
|
||||
# Snapshot bucket MGET chunk size
|
||||
# 调度快照分桶读取时的 MGET 分块大小
|
||||
snapshot_mget_chunk_size: 128
|
||||
# Snapshot bucket write chunk size
|
||||
# 调度快照重建写入时的分块大小
|
||||
snapshot_write_chunk_size: 256
|
||||
# Slot cleanup interval (duration)
|
||||
# 并发槽位清理周期(时间段)
|
||||
slot_cleanup_interval: 30s
|
||||
@@ -826,6 +846,46 @@ linuxdo_connect:
|
||||
userinfo_id_path: ""
|
||||
userinfo_username_path: ""
|
||||
|
||||
# =============================================================================
|
||||
# Generic OIDC OAuth Login (SSO)
|
||||
# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录)
|
||||
# =============================================================================
|
||||
oidc_connect:
|
||||
enabled: false
|
||||
provider_name: "OIDC"
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
# 例如: "https://keycloak.example.com/realms/myrealm"
|
||||
issuer_url: ""
|
||||
# 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks
|
||||
discovery_url: ""
|
||||
authorize_url: ""
|
||||
token_url: ""
|
||||
# 可选(仅补充 email/username,不用于 sub 可信绑定)
|
||||
userinfo_url: ""
|
||||
# validate_id_token=true 时必填
|
||||
jwks_url: ""
|
||||
scopes: "openid email profile"
|
||||
# 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback"
|
||||
redirect_url: ""
|
||||
# 安全提示:
|
||||
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
|
||||
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
|
||||
frontend_redirect_url: "/auth/oidc/callback"
|
||||
token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none
|
||||
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
|
||||
use_pkce: false
|
||||
# 开启后强制校验 id_token 的签名和 claims(推荐)
|
||||
validate_id_token: true
|
||||
allowed_signing_algs: "RS256,ES256,PS256"
|
||||
# 允许的时钟偏移(秒)
|
||||
clock_skew_seconds: 120
|
||||
# 若 Provider 返回 email_verified=false,是否拒绝登录
|
||||
require_email_verified: false
|
||||
userinfo_email_path: ""
|
||||
userinfo_id_path: ""
|
||||
userinfo_username_path: ""
|
||||
|
||||
# =============================================================================
|
||||
# Default Settings
|
||||
# 默认设置
|
||||
|
||||
@@ -31,6 +31,10 @@ services:
|
||||
# Optional: Mount custom config.yaml (uncomment and create the file first)
|
||||
# Copy config.example.yaml to config.yaml, modify it, then uncomment:
|
||||
# - ./config.yaml:/app/data/config.yaml
|
||||
# Optional: Mount a custom Codex instructions template file, then point
|
||||
# gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl
|
||||
# in config.yaml.
|
||||
# - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro
|
||||
environment:
|
||||
# =======================================================================
|
||||
# Auto Setup (REQUIRED for Docker deployment)
|
||||
@@ -146,7 +150,17 @@ services:
|
||||
networks:
|
||||
- sub2api-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"-q",
|
||||
"-T",
|
||||
"5",
|
||||
"-O",
|
||||
"/dev/null",
|
||||
"http://localhost:8080/health",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -177,11 +191,17 @@ services:
|
||||
networks:
|
||||
- sub2api-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- 5432:5432
|
||||
# 注意:不暴露端口到宿主机,应用通过内部网络连接
|
||||
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
|
||||
|
||||
@@ -199,12 +219,12 @@ services:
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: >
|
||||
sh -c '
|
||||
redis-server
|
||||
--save 60 1
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||
sh -c '
|
||||
redis-server
|
||||
--save 60 1
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
|
||||
@@ -217,7 +237,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
ports:
|
||||
- 6379:6379
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
|
||||
@@ -64,6 +64,30 @@ export interface SystemSettings {
|
||||
linuxdo_connect_client_secret_configured: boolean
|
||||
linuxdo_connect_redirect_url: string
|
||||
|
||||
// Generic OIDC OAuth settings
|
||||
oidc_connect_enabled: boolean
|
||||
oidc_connect_provider_name: string
|
||||
oidc_connect_client_id: string
|
||||
oidc_connect_client_secret_configured: boolean
|
||||
oidc_connect_issuer_url: string
|
||||
oidc_connect_discovery_url: string
|
||||
oidc_connect_authorize_url: string
|
||||
oidc_connect_token_url: string
|
||||
oidc_connect_userinfo_url: string
|
||||
oidc_connect_jwks_url: string
|
||||
oidc_connect_scopes: string
|
||||
oidc_connect_redirect_url: string
|
||||
oidc_connect_frontend_redirect_url: string
|
||||
oidc_connect_token_auth_method: string
|
||||
oidc_connect_use_pkce: boolean
|
||||
oidc_connect_validate_id_token: boolean
|
||||
oidc_connect_allowed_signing_algs: string
|
||||
oidc_connect_clock_skew_seconds: number
|
||||
oidc_connect_require_email_verified: boolean
|
||||
oidc_connect_userinfo_email_path: string
|
||||
oidc_connect_userinfo_id_path: string
|
||||
oidc_connect_userinfo_username_path: string
|
||||
|
||||
// Model fallback configuration
|
||||
enable_model_fallback: boolean
|
||||
fallback_model_anthropic: string
|
||||
@@ -135,6 +159,28 @@ export interface UpdateSettingsRequest {
|
||||
linuxdo_connect_client_id?: string
|
||||
linuxdo_connect_client_secret?: string
|
||||
linuxdo_connect_redirect_url?: string
|
||||
oidc_connect_enabled?: boolean
|
||||
oidc_connect_provider_name?: string
|
||||
oidc_connect_client_id?: string
|
||||
oidc_connect_client_secret?: string
|
||||
oidc_connect_issuer_url?: string
|
||||
oidc_connect_discovery_url?: string
|
||||
oidc_connect_authorize_url?: string
|
||||
oidc_connect_token_url?: string
|
||||
oidc_connect_userinfo_url?: string
|
||||
oidc_connect_jwks_url?: string
|
||||
oidc_connect_scopes?: string
|
||||
oidc_connect_redirect_url?: string
|
||||
oidc_connect_frontend_redirect_url?: string
|
||||
oidc_connect_token_auth_method?: string
|
||||
oidc_connect_use_pkce?: boolean
|
||||
oidc_connect_validate_id_token?: boolean
|
||||
oidc_connect_allowed_signing_algs?: string
|
||||
oidc_connect_clock_skew_seconds?: number
|
||||
oidc_connect_require_email_verified?: boolean
|
||||
oidc_connect_userinfo_email_path?: string
|
||||
oidc_connect_userinfo_id_path?: string
|
||||
oidc_connect_userinfo_username_path?: string
|
||||
enable_model_fallback?: boolean
|
||||
fallback_model_anthropic?: string
|
||||
fallback_model_openai?: string
|
||||
|
||||
@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OIDC OAuth registration by supplying an invitation code
|
||||
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
|
||||
* @param invitationCode - Invitation code entered by the user
|
||||
* @returns Token pair on success
|
||||
*/
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
pendingOAuthToken: string,
|
||||
invitationCode: string
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/oidc/complete-registration', {
|
||||
pending_oauth_token: pendingOAuthToken,
|
||||
invitation_code: invitationCode
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
login,
|
||||
login2FA,
|
||||
@@ -380,7 +402,8 @@ export const authAPI = {
|
||||
resetPassword,
|
||||
refreshToken,
|
||||
revokeAllSessions,
|
||||
completeLinuxDoOAuthRegistration
|
||||
completeLinuxDoOAuthRegistration,
|
||||
completeOIDCOAuthRegistration
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
{{ t('auth.linuxdo.signIn') }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="showDivider" class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.linuxdo.orContinue') }}
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -43,9 +43,12 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -58,4 +61,3 @@ function startLogin(): void {
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
53
frontend/src/components/auth/OidcOAuthSection.vue
Normal file
53
frontend/src/components/auth/OidcOAuthSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
|
||||
<span
|
||||
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ providerInitial }}
|
||||
</span>
|
||||
{{ t('auth.oidc.signIn', { providerName: normalizedProviderName }) }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDivider" class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
providerName?: string
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
providerName: 'OIDC',
|
||||
showDivider: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const normalizedProviderName = computed(() => {
|
||||
const name = props.providerName?.trim()
|
||||
return name || 'OIDC'
|
||||
})
|
||||
|
||||
const providerInitial = computed(() => normalizedProviderName.value.charAt(0).toUpperCase() || 'O')
|
||||
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
@@ -428,6 +428,7 @@ export default {
|
||||
invitationCodeInvalid: 'Invalid or used invitation code',
|
||||
invitationCodeValidating: 'Validating invitation code...',
|
||||
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
||||
oauthOrContinue: 'or continue with email',
|
||||
linuxdo: {
|
||||
signIn: 'Continue with Linux.do',
|
||||
orContinue: 'or continue with email',
|
||||
@@ -442,6 +443,20 @@ export default {
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
oidc: {
|
||||
signIn: 'Continue with {providerName}',
|
||||
callbackTitle: 'Signing you in with {providerName}',
|
||||
callbackProcessing: 'Completing login with {providerName}, please wait...',
|
||||
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
||||
callbackMissingToken: 'Missing login token, please try again.',
|
||||
backToLogin: 'Back to Login',
|
||||
invitationRequired:
|
||||
'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
|
||||
invalidPendingToken: 'The registration token has expired. Please sign in again.',
|
||||
completeRegistration: 'Complete Registration',
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
oauth: {
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
@@ -4228,6 +4243,57 @@ export default {
|
||||
quickSetCopy: 'Generate & Copy (current site)',
|
||||
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
|
||||
},
|
||||
oidc: {
|
||||
title: 'OIDC Login',
|
||||
description: 'Configure a standard OIDC provider (for example Keycloak)',
|
||||
enable: 'Enable OIDC Login',
|
||||
enableHint: 'Show OIDC login on the login/register pages',
|
||||
providerName: 'Provider Name',
|
||||
providerNamePlaceholder: 'for example Keycloak',
|
||||
clientId: 'Client ID',
|
||||
clientIdPlaceholder: 'OIDC client id',
|
||||
clientSecret: 'Client Secret',
|
||||
clientSecretPlaceholder: '********',
|
||||
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
|
||||
clientSecretConfiguredPlaceholder: '********',
|
||||
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
|
||||
issuerUrl: 'Issuer URL',
|
||||
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
|
||||
discoveryUrl: 'Discovery URL',
|
||||
discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer',
|
||||
authorizeUrl: 'Authorize URL',
|
||||
authorizeUrlPlaceholder: 'Optional, can be discovered automatically',
|
||||
tokenUrl: 'Token URL',
|
||||
tokenUrlPlaceholder: 'Optional, can be discovered automatically',
|
||||
userinfoUrl: 'UserInfo URL',
|
||||
userinfoUrlPlaceholder: 'Optional, can be discovered automatically',
|
||||
jwksUrl: 'JWKS URL',
|
||||
jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled',
|
||||
scopes: 'Scopes',
|
||||
scopesPlaceholder: 'openid email profile',
|
||||
scopesHint: 'Must include openid',
|
||||
redirectUrl: 'Backend Redirect URL',
|
||||
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
|
||||
redirectUrlHint: 'Must match the callback URL configured in the OIDC provider',
|
||||
quickSetCopy: 'Generate & Copy (current site)',
|
||||
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard',
|
||||
frontendRedirectUrl: 'Frontend Callback Path',
|
||||
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
|
||||
frontendRedirectUrlHint: 'Frontend route used after backend callback',
|
||||
tokenAuthMethod: 'Token Auth Method',
|
||||
clockSkewSeconds: 'Clock Skew (seconds)',
|
||||
allowedSigningAlgs: 'Allowed Signing Algs',
|
||||
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
|
||||
usePkce: 'Use PKCE',
|
||||
validateIdToken: 'Validate ID Token',
|
||||
requireEmailVerified: 'Require Email Verified',
|
||||
userinfoEmailPath: 'UserInfo Email Path',
|
||||
userinfoEmailPathPlaceholder: 'for example data.email',
|
||||
userinfoIdPath: 'UserInfo ID Path',
|
||||
userinfoIdPathPlaceholder: 'for example data.id',
|
||||
userinfoUsernamePath: 'UserInfo Username Path',
|
||||
userinfoUsernamePathPlaceholder: 'for example data.username'
|
||||
},
|
||||
defaults: {
|
||||
title: 'Default User Settings',
|
||||
description: 'Default values for new users',
|
||||
|
||||
@@ -427,6 +427,7 @@ export default {
|
||||
invitationCodeInvalid: '邀请码无效或已被使用',
|
||||
invitationCodeValidating: '正在验证邀请码...',
|
||||
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
||||
oauthOrContinue: '或使用邮箱密码继续',
|
||||
linuxdo: {
|
||||
signIn: '使用 Linux.do 登录',
|
||||
orContinue: '或使用邮箱密码继续',
|
||||
@@ -441,6 +442,19 @@ export default {
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
oidc: {
|
||||
signIn: '使用 {providerName} 登录',
|
||||
callbackTitle: '正在完成 {providerName} 登录',
|
||||
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
|
||||
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||
backToLogin: '返回登录',
|
||||
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||
invalidPendingToken: '注册凭证已失效,请重新登录。',
|
||||
completeRegistration: '完成注册',
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
oauth: {
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
@@ -4394,6 +4408,57 @@ export default {
|
||||
quickSetCopy: '使用当前站点生成并复制',
|
||||
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
|
||||
},
|
||||
oidc: {
|
||||
title: 'OIDC 登录',
|
||||
description: '配置标准 OIDC Provider(例如 Keycloak)',
|
||||
enable: '启用 OIDC 登录',
|
||||
enableHint: '在登录/注册页面显示 OIDC 登录入口',
|
||||
providerName: 'Provider 名称',
|
||||
providerNamePlaceholder: '例如 Keycloak',
|
||||
clientId: 'Client ID',
|
||||
clientIdPlaceholder: 'OIDC client id',
|
||||
clientSecret: 'Client Secret',
|
||||
clientSecretPlaceholder: '********',
|
||||
clientSecretHint: '用于后端交换 token(请保密)',
|
||||
clientSecretConfiguredPlaceholder: '********',
|
||||
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
|
||||
issuerUrl: 'Issuer URL',
|
||||
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
|
||||
discoveryUrl: 'Discovery URL',
|
||||
discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导',
|
||||
authorizeUrl: 'Authorize URL',
|
||||
authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取',
|
||||
tokenUrl: 'Token URL',
|
||||
tokenUrlPlaceholder: '可选,可通过 discovery 自动获取',
|
||||
userinfoUrl: 'UserInfo URL',
|
||||
userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取',
|
||||
jwksUrl: 'JWKS URL',
|
||||
jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填',
|
||||
scopes: 'Scopes',
|
||||
scopesPlaceholder: 'openid email profile',
|
||||
scopesHint: '必须包含 openid',
|
||||
redirectUrl: '后端回调地址(Redirect URL)',
|
||||
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
|
||||
redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致',
|
||||
quickSetCopy: '使用当前站点生成并复制',
|
||||
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板',
|
||||
frontendRedirectUrl: '前端回调路径',
|
||||
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
|
||||
frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径',
|
||||
tokenAuthMethod: 'Token 鉴权方式',
|
||||
clockSkewSeconds: '时钟偏移(秒)',
|
||||
allowedSigningAlgs: '允许的签名算法',
|
||||
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
|
||||
usePkce: '启用 PKCE',
|
||||
validateIdToken: '校验 ID Token',
|
||||
requireEmailVerified: '要求邮箱已验证',
|
||||
userinfoEmailPath: 'UserInfo 邮箱字段路径',
|
||||
userinfoEmailPathPlaceholder: '例如 data.email',
|
||||
userinfoIdPath: 'UserInfo ID 字段路径',
|
||||
userinfoIdPathPlaceholder: '例如 data.id',
|
||||
userinfoUsernamePath: 'UserInfo 用户名字段路径',
|
||||
userinfoUsernamePathPlaceholder: '例如 data.username'
|
||||
},
|
||||
defaults: {
|
||||
title: '用户默认设置',
|
||||
description: '新用户的默认值',
|
||||
|
||||
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'LinuxDo OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/auth/oidc/callback',
|
||||
name: 'OIDCOAuthCallback',
|
||||
component: () => import('@/views/auth/OidcCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'OIDC OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
|
||||
@@ -339,6 +339,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
oidc_oauth_enabled: false,
|
||||
oidc_oauth_provider_name: 'OIDC',
|
||||
sora_client_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ export interface PublicSettings {
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
linuxdo_oauth_enabled: boolean
|
||||
oidc_oauth_enabled: boolean
|
||||
oidc_oauth_provider_name: string
|
||||
sora_client_enabled: boolean
|
||||
backend_mode_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -368,6 +371,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription'
|
||||
|
||||
export interface OpenAIMessagesDispatchModelConfig {
|
||||
opus_mapped_model?: string
|
||||
sonnet_mapped_model?: string
|
||||
haiku_mapped_model?: string
|
||||
exact_model_mappings?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number
|
||||
name: string
|
||||
@@ -390,6 +400,8 @@ export interface Group {
|
||||
fallback_group_id_on_invalid_request: number | null
|
||||
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
|
||||
allow_messages_dispatch?: boolean
|
||||
default_mapped_model?: string
|
||||
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||
require_oauth_only: boolean
|
||||
require_privacy_set: boolean
|
||||
created_at: string
|
||||
@@ -416,6 +428,7 @@ export interface AdminGroup extends Group {
|
||||
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
default_mapped_model?: string
|
||||
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
|
||||
|
||||
// 分组排序
|
||||
sort_order: number
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1124,7 +1124,327 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Security — Registration, Turnstile, LinuxDo -->
|
||||
|
||||
<!-- Generic OIDC OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.oidc.enable')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.enableHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_enabled" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.oidc_connect_enabled"
|
||||
class="space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.providerName') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_provider_name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.settings.oidc.providerNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.clientId') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_client_id"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.clientIdPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.clientSecret') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_client_secret"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
form.oidc_connect_client_secret_configured
|
||||
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
|
||||
: t('admin.settings.oidc.clientSecretPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
form.oidc_connect_client_secret_configured
|
||||
? t('admin.settings.oidc.clientSecretConfiguredHint')
|
||||
: t('admin.settings.oidc.clientSecretHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.issuerUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_issuer_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.issuerUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.discoveryUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_discovery_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.discoveryUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.authorizeUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_authorize_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.authorizeUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.tokenUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_token_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.tokenUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.jwksUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_jwks_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.jwksUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.scopes') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_scopes"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.scopesPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.scopesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.redirectUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_redirect_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.redirectUrlPlaceholder')"
|
||||
/>
|
||||
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm w-fit"
|
||||
@click="setAndCopyOIDCRedirectUrl"
|
||||
>
|
||||
{{ t('admin.settings.oidc.quickSetCopy') }}
|
||||
</button>
|
||||
<code
|
||||
v-if="oidcRedirectUrlSuggestion"
|
||||
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
|
||||
>
|
||||
{{ oidcRedirectUrlSuggestion }}
|
||||
</code>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.redirectUrlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.frontendRedirectUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_frontend_redirect_url"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.frontendRedirectUrlPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.frontendRedirectUrlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.tokenAuthMethod') }}
|
||||
</label>
|
||||
<select v-model="form.oidc_connect_token_auth_method" class="input font-mono text-sm">
|
||||
<option value="client_secret_post">client_secret_post</option>
|
||||
<option value="client_secret_basic">client_secret_basic</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.clockSkewSeconds') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.oidc_connect_clock_skew_seconds"
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.allowedSigningAlgs') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_allowed_signing_algs"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.allowedSigningAlgsPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.usePkce') }}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_use_pkce" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.validateIdToken') }}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_validate_id_token" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.requireEmailVerified') }}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_require_email_verified" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoEmailPath') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_email_path"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoEmailPathPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoIdPath') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_id_path"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoIdPathPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoUsernamePath') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_username_path"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoUsernamePathPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Security — Registration, Turnstile, LinuxDo, OIDC -->
|
||||
|
||||
<!-- Tab: Users -->
|
||||
<div v-show="activeTab === 'users'" class="space-y-6">
|
||||
@@ -2240,6 +2560,7 @@ type SettingsForm = SystemSettings & {
|
||||
smtp_password: string
|
||||
turnstile_secret_key: string
|
||||
linuxdo_connect_client_secret: string
|
||||
oidc_connect_client_secret: string
|
||||
}
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
@@ -2289,6 +2610,30 @@ const form = reactive<SettingsForm>({
|
||||
linuxdo_connect_client_secret: '',
|
||||
linuxdo_connect_client_secret_configured: false,
|
||||
linuxdo_connect_redirect_url: '',
|
||||
// Generic OIDC OAuth 登录
|
||||
oidc_connect_enabled: false,
|
||||
oidc_connect_provider_name: 'OIDC',
|
||||
oidc_connect_client_id: '',
|
||||
oidc_connect_client_secret: '',
|
||||
oidc_connect_client_secret_configured: false,
|
||||
oidc_connect_issuer_url: '',
|
||||
oidc_connect_discovery_url: '',
|
||||
oidc_connect_authorize_url: '',
|
||||
oidc_connect_token_url: '',
|
||||
oidc_connect_userinfo_url: '',
|
||||
oidc_connect_jwks_url: '',
|
||||
oidc_connect_scopes: 'openid email profile',
|
||||
oidc_connect_redirect_url: '',
|
||||
oidc_connect_frontend_redirect_url: '/auth/oidc/callback',
|
||||
oidc_connect_token_auth_method: 'client_secret_post',
|
||||
oidc_connect_use_pkce: false,
|
||||
oidc_connect_validate_id_token: true,
|
||||
oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256',
|
||||
oidc_connect_clock_skew_seconds: 120,
|
||||
oidc_connect_require_email_verified: false,
|
||||
oidc_connect_userinfo_email_path: '',
|
||||
oidc_connect_userinfo_id_path: '',
|
||||
oidc_connect_userinfo_username_path: '',
|
||||
// Model fallback
|
||||
enable_model_fallback: false,
|
||||
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
|
||||
@@ -2409,6 +2754,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
||||
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
|
||||
}
|
||||
|
||||
const oidcRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const origin =
|
||||
window.location.origin || `${window.location.protocol}//${window.location.host}`
|
||||
return `${origin}/api/v1/auth/oauth/oidc/callback`
|
||||
})
|
||||
|
||||
async function setAndCopyOIDCRedirectUrl() {
|
||||
const url = oidcRedirectUrlSuggestion.value
|
||||
if (!url) return
|
||||
|
||||
form.oidc_connect_redirect_url = url
|
||||
await copyToClipboard(url, t('admin.settings.oidc.redirectUrlSetAndCopied'))
|
||||
}
|
||||
|
||||
// Custom menu item management
|
||||
function addMenuItem() {
|
||||
form.custom_menu_items.push({
|
||||
@@ -2506,6 +2866,7 @@ async function loadSettings() {
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
form.oidc_connect_client_secret = ''
|
||||
} catch (error: any) {
|
||||
loadFailed.value = true
|
||||
appStore.showError(
|
||||
@@ -2673,6 +3034,28 @@ async function saveSettings() {
|
||||
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
||||
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
|
||||
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
|
||||
oidc_connect_enabled: form.oidc_connect_enabled,
|
||||
oidc_connect_provider_name: form.oidc_connect_provider_name,
|
||||
oidc_connect_client_id: form.oidc_connect_client_id,
|
||||
oidc_connect_client_secret: form.oidc_connect_client_secret || undefined,
|
||||
oidc_connect_issuer_url: form.oidc_connect_issuer_url,
|
||||
oidc_connect_discovery_url: form.oidc_connect_discovery_url,
|
||||
oidc_connect_authorize_url: form.oidc_connect_authorize_url,
|
||||
oidc_connect_token_url: form.oidc_connect_token_url,
|
||||
oidc_connect_userinfo_url: form.oidc_connect_userinfo_url,
|
||||
oidc_connect_jwks_url: form.oidc_connect_jwks_url,
|
||||
oidc_connect_scopes: form.oidc_connect_scopes,
|
||||
oidc_connect_redirect_url: form.oidc_connect_redirect_url,
|
||||
oidc_connect_frontend_redirect_url: form.oidc_connect_frontend_redirect_url,
|
||||
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
|
||||
oidc_connect_use_pkce: form.oidc_connect_use_pkce,
|
||||
oidc_connect_validate_id_token: form.oidc_connect_validate_id_token,
|
||||
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
|
||||
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
|
||||
oidc_connect_require_email_verified: form.oidc_connect_require_email_verified,
|
||||
oidc_connect_userinfo_email_path: form.oidc_connect_userinfo_email_path,
|
||||
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
|
||||
oidc_connect_userinfo_username_path: form.oidc_connect_userinfo_username_path,
|
||||
enable_model_fallback: form.enable_model_fallback,
|
||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||
fallback_model_openai: form.fallback_model_openai,
|
||||
@@ -2700,6 +3083,7 @@ async function saveSettings() {
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
form.oidc_connect_client_secret = ''
|
||||
// Refresh cached settings so sidebar/header update immediately
|
||||
await appStore.fetchPublicSettings(true)
|
||||
await adminSettingsStore.fetch(true)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createDefaultMessagesDispatchFormState,
|
||||
messagesDispatchConfigToFormState,
|
||||
messagesDispatchFormStateToConfig,
|
||||
resetMessagesDispatchFormState,
|
||||
} from "../groupsMessagesDispatch";
|
||||
|
||||
describe("groupsMessagesDispatch", () => {
|
||||
it("returns the expected default form state", () => {
|
||||
expect(createDefaultMessagesDispatchFormState()).toEqual({
|
||||
allow_messages_dispatch: false,
|
||||
opus_mapped_model: "gpt-5.4",
|
||||
sonnet_mapped_model: "gpt-5.3-codex",
|
||||
haiku_mapped_model: "gpt-5.4-mini",
|
||||
exact_model_mappings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes exact model mapping rows when converting to config", () => {
|
||||
const config = messagesDispatchFormStateToConfig({
|
||||
allow_messages_dispatch: true,
|
||||
opus_mapped_model: " gpt-5.4 ",
|
||||
sonnet_mapped_model: "gpt-5.3-codex",
|
||||
haiku_mapped_model: " gpt-5.4-mini ",
|
||||
exact_model_mappings: [
|
||||
{
|
||||
claude_model: " claude-sonnet-4-5-20250929 ",
|
||||
target_model: " gpt-5.2 ",
|
||||
},
|
||||
{ claude_model: "", target_model: "gpt-5.4" },
|
||||
{ claude_model: "claude-opus-4-6", target_model: " " },
|
||||
],
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
opus_mapped_model: "gpt-5.4",
|
||||
sonnet_mapped_model: "gpt-5.3-codex",
|
||||
haiku_mapped_model: "gpt-5.4-mini",
|
||||
exact_model_mappings: {
|
||||
"claude-sonnet-4-5-20250929": "gpt-5.2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates form state from api config", () => {
|
||||
expect(
|
||||
messagesDispatchConfigToFormState({
|
||||
opus_mapped_model: "gpt-5.4",
|
||||
sonnet_mapped_model: "gpt-5.2",
|
||||
haiku_mapped_model: "gpt-5.4-mini",
|
||||
exact_model_mappings: {
|
||||
"claude-opus-4-6": "gpt-5.4",
|
||||
"claude-haiku-4-5-20251001": "gpt-5.4-mini",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
allow_messages_dispatch: false,
|
||||
opus_mapped_model: "gpt-5.4",
|
||||
sonnet_mapped_model: "gpt-5.2",
|
||||
haiku_mapped_model: "gpt-5.4-mini",
|
||||
exact_model_mappings: [
|
||||
{
|
||||
claude_model: "claude-haiku-4-5-20251001",
|
||||
target_model: "gpt-5.4-mini",
|
||||
},
|
||||
{ claude_model: "claude-opus-4-6", target_model: "gpt-5.4" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("resets mutable form state when platform switches away from openai", () => {
|
||||
const state = {
|
||||
allow_messages_dispatch: true,
|
||||
opus_mapped_model: "gpt-5.2",
|
||||
sonnet_mapped_model: "gpt-5.4",
|
||||
haiku_mapped_model: "gpt-5.1",
|
||||
exact_model_mappings: [
|
||||
{ claude_model: "claude-opus-4-6", target_model: "gpt-5.4" },
|
||||
],
|
||||
};
|
||||
|
||||
resetMessagesDispatchFormState(state);
|
||||
|
||||
expect(state).toEqual({
|
||||
allow_messages_dispatch: false,
|
||||
opus_mapped_model: "gpt-5.4",
|
||||
sonnet_mapped_model: "gpt-5.3-codex",
|
||||
haiku_mapped_model: "gpt-5.4-mini",
|
||||
exact_model_mappings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
72
frontend/src/views/admin/groupsMessagesDispatch.ts
Normal file
72
frontend/src/views/admin/groupsMessagesDispatch.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { OpenAIMessagesDispatchModelConfig } from "@/types";
|
||||
|
||||
export interface MessagesDispatchMappingRow {
|
||||
claude_model: string;
|
||||
target_model: string;
|
||||
}
|
||||
|
||||
export interface MessagesDispatchFormState {
|
||||
allow_messages_dispatch: boolean;
|
||||
opus_mapped_model: string;
|
||||
sonnet_mapped_model: string;
|
||||
haiku_mapped_model: string;
|
||||
exact_model_mappings: MessagesDispatchMappingRow[];
|
||||
}
|
||||
|
||||
export function createDefaultMessagesDispatchFormState(): MessagesDispatchFormState {
|
||||
return {
|
||||
allow_messages_dispatch: false,
|
||||
opus_mapped_model: "gpt-5.4",
|
||||
sonnet_mapped_model: "gpt-5.3-codex",
|
||||
haiku_mapped_model: "gpt-5.4-mini",
|
||||
exact_model_mappings: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function messagesDispatchConfigToFormState(
|
||||
config?: OpenAIMessagesDispatchModelConfig | null,
|
||||
): MessagesDispatchFormState {
|
||||
const defaults = createDefaultMessagesDispatchFormState();
|
||||
const exactMappings = Object.entries(config?.exact_model_mappings || {})
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([claude_model, target_model]) => ({ claude_model, target_model }));
|
||||
|
||||
return {
|
||||
allow_messages_dispatch: false,
|
||||
opus_mapped_model:
|
||||
config?.opus_mapped_model?.trim() || defaults.opus_mapped_model,
|
||||
sonnet_mapped_model:
|
||||
config?.sonnet_mapped_model?.trim() || defaults.sonnet_mapped_model,
|
||||
haiku_mapped_model:
|
||||
config?.haiku_mapped_model?.trim() || defaults.haiku_mapped_model,
|
||||
exact_model_mappings: exactMappings,
|
||||
};
|
||||
}
|
||||
|
||||
export function messagesDispatchFormStateToConfig(
|
||||
state: MessagesDispatchFormState,
|
||||
): OpenAIMessagesDispatchModelConfig {
|
||||
const exactModelMappings = Object.fromEntries(
|
||||
state.exact_model_mappings
|
||||
.map((row) => [row.claude_model.trim(), row.target_model.trim()] as const)
|
||||
.filter(([claudeModel, targetModel]) => claudeModel && targetModel),
|
||||
);
|
||||
|
||||
return {
|
||||
opus_mapped_model: state.opus_mapped_model.trim(),
|
||||
sonnet_mapped_model: state.sonnet_mapped_model.trim(),
|
||||
haiku_mapped_model: state.haiku_mapped_model.trim(),
|
||||
exact_model_mappings: exactModelMappings,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetMessagesDispatchFormState(
|
||||
target: MessagesDispatchFormState,
|
||||
): void {
|
||||
const defaults = createDefaultMessagesDispatchFormState();
|
||||
target.allow_messages_dispatch = defaults.allow_messages_dispatch;
|
||||
target.opus_mapped_model = defaults.opus_mapped_model;
|
||||
target.sonnet_mapped_model = defaults.sonnet_mapped_model;
|
||||
target.haiku_mapped_model = defaults.haiku_mapped_model;
|
||||
target.exact_model_mappings = [];
|
||||
}
|
||||
@@ -11,8 +11,26 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- LinuxDo Connect OAuth 登录 -->
|
||||
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled && !backendModeEnabled" :disabled="isLoading" />
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const backendModeEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
|
||||
// Turnstile
|
||||
@@ -247,6 +268,9 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
|
||||
234
frontend/src/views/auth/OidcCallbackView.vue
Normal file
234
frontend/src/views/auth/OidcCallbackView.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
isProcessing
|
||||
? t('auth.oidc.callbackProcessing', { providerName })
|
||||
: t('auth.oidc.callbackHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">
|
||||
{{ t('auth.oidc.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeOIDCOAuthRegistration,
|
||||
getPublicSettings
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const needsInvitation = ref(false)
|
||||
const pendingOAuthToken = ref('')
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const providerName = ref('OIDC')
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
if (!path) return '/dashboard'
|
||||
if (!path.startsWith('/')) return '/dashboard'
|
||||
if (path.startsWith('//')) return '/dashboard'
|
||||
if (path.includes('://')) return '/dashboard'
|
||||
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
async function loadProviderName() {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
const name = settings.oidc_oauth_provider_name?.trim()
|
||||
if (name) {
|
||||
providerName.value = name
|
||||
}
|
||||
} catch {
|
||||
// Ignore; fallback remains OIDC
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeOIDCOAuthRegistration(
|
||||
pendingOAuthToken.value,
|
||||
invitationCode.value.trim()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
invitationError.value =
|
||||
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void loadProviderName()
|
||||
|
||||
const params = parseFragmentParams()
|
||||
const token = params.get('access_token') || ''
|
||||
const refreshToken = params.get('refresh_token') || ''
|
||||
const expiresInStr = params.get('expires_in') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
if (error === 'invitation_required') {
|
||||
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||
if (!pendingOAuthToken.value) {
|
||||
errorMessage.value = t('auth.oidc.invalidPendingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
errorMessage.value = t('auth.oidc.callbackMissingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
if (expiresInStr) {
|
||||
const expiresIn = parseInt(expiresInStr, 10)
|
||||
if (!isNaN(expiresIn)) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
await authStore.setToken(token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -11,8 +11,26 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- LinuxDo Connect OAuth 登录 -->
|
||||
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
|
||||
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Disabled Message -->
|
||||
<div
|
||||
@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile
|
||||
@@ -376,6 +397,8 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user