1
0
forked from baron/baron-sso

Implement tenant import and RP auto login policies

This commit is contained in:
2026-04-30 15:45:34 +09:00
parent 24807eab0f
commit f7e4d43b16
76 changed files with 5307 additions and 441 deletions

View File

@@ -0,0 +1,196 @@
package domain
import (
"errors"
"strings"
"unicode"
)
var hanmacSurnameRomanization = map[rune]string{
'한': "han",
'김': "kim",
'이': "lee",
'박': "park",
'최': "choi",
'정': "jung",
'조': "cho",
'강': "kang",
'윤': "yoon",
'장': "jang",
'임': "lim",
'림': "lim",
'신': "shin",
'오': "oh",
'서': "seo",
'권': "kwon",
'황': "hwang",
'안': "ahn",
'송': "song",
'전': "jeon",
'홍': "hong",
'유': "yoo",
'고': "ko",
'문': "moon",
'양': "yang",
'손': "son",
'배': "bae",
'백': "baek",
'허': "heo",
'남': "nam",
'심': "sim",
'노': "noh",
'하': "ha",
'곽': "kwak",
'성': "sung",
'차': "cha",
'주': "joo",
'우': "woo",
'구': "koo",
'민': "min",
'류': "ryu",
'나': "na",
'진': "jin",
'지': "ji",
'엄': "um",
'채': "chae",
'원': "won",
'천': "cheon",
'방': "bang",
'공': "gong",
'현': "hyun",
'함': "ham",
'여': "yeo",
'추': "choo",
'도': "do",
'소': "so",
'석': "seok",
'선': "sun",
'설': "seol",
'마': "ma",
'길': "gil",
'연': "yeon",
'위': "wi",
'표': "pyo",
'명': "myung",
'기': "ki",
'반': "ban",
'라': "ra",
'왕': "wang",
'금': "geum",
'옥': "ok",
'육': "yook",
'인': "in",
'맹': "maeng",
'제': "je",
'모': "mo",
'탁': "tak",
'국': "guk",
'어': "eo",
'은': "eun",
'편': "pyeon",
'용': "yong",
}
var hanmacInitialRomanization = []string{
"g", "g", "n", "d", "d", "r", "m", "b", "b", "s",
"s", "y", "j", "j", "c", "k", "t", "p", "h",
}
func SplitEmailDomain(email string) (string, string, error) {
normalized := strings.ToLower(strings.TrimSpace(email))
at := strings.Index(normalized, "@")
if at < 0 {
return "", "", errors.New("email must contain @")
}
if strings.Count(normalized, "@") != 1 {
return "", "", errors.New("email must contain one @")
}
localPart := strings.TrimSpace(normalized[:at])
domainPart := strings.TrimSpace(normalized[at+1:])
if domainPart == "" || !strings.Contains(domainPart, ".") {
return "", "", errors.New("email domain is invalid")
}
return localPart, domainPart, nil
}
func ExtractNormalizedEmailLocalPart(email string) (string, error) {
localPart, _, err := SplitEmailDomain(email)
if err != nil {
return "", err
}
if localPart == "" {
return "", errors.New("email local-part is empty")
}
return localPart, nil
}
func BuildKoreanNameEmailBase(name string) (string, bool, error) {
runes := compactNameRunes(name)
if len(runes) < 2 {
return "", true, nil
}
surname, ok := hanmacSurnameRomanization[runes[0]]
if !ok {
return "", true, nil
}
var builder strings.Builder
for _, r := range runes[1:] {
initial, ok := romanizedHangulInitial(r)
if !ok {
return "", true, nil
}
builder.WriteString(initial)
}
builder.WriteString(surname)
return builder.String(), false, nil
}
func MatchesSuggestedNameRule(localPart string, base string) bool {
localPart = strings.ToLower(strings.TrimSpace(localPart))
base = strings.ToLower(strings.TrimSpace(base))
if localPart == "" || base == "" {
return false
}
if localPart == base {
return true
}
if !strings.HasPrefix(localPart, base) {
return false
}
suffix := localPart[len(base):]
if suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
func compactNameRunes(name string) []rune {
var runes []rune
for _, r := range strings.TrimSpace(name) {
if unicode.IsSpace(r) {
continue
}
runes = append(runes, r)
}
return runes
}
func romanizedHangulInitial(r rune) (string, bool) {
const hangulBase = 0xAC00
const hangulEnd = 0xD7A3
if r < hangulBase || r > hangulEnd {
return "", false
}
index := int(r-hangulBase) / 588
if index < 0 || index >= len(hanmacInitialRomanization) {
return "", false
}
return hanmacInitialRomanization[index], true
}

View File

@@ -0,0 +1,76 @@
package domain
import "testing"
func TestSplitEmailDomainAllowsDomainOnlyImportInput(t *testing.T) {
tests := []struct {
name string
email string
wantLocal string
wantDomain string
}{
{name: "full address", email: " Han@SamanEng.com ", wantLocal: "han", wantDomain: "samaneng.com"},
{name: "domain only", email: "@hanmaceng.co.kr", wantLocal: "", wantDomain: "hanmaceng.co.kr"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
local, domain, err := SplitEmailDomain(tt.email)
if err != nil {
t.Fatalf("SplitEmailDomain() error = %v", err)
}
if local != tt.wantLocal || domain != tt.wantDomain {
t.Fatalf("SplitEmailDomain() = (%q, %q), want (%q, %q)", local, domain, tt.wantLocal, tt.wantDomain)
}
})
}
}
func TestBuildKoreanNameEmailBase(t *testing.T) {
base, needsReview, err := BuildKoreanNameEmailBase("한치영")
if err != nil {
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
}
if needsReview {
t.Fatalf("BuildKoreanNameEmailBase() needsReview = true")
}
if base != "cyhan" {
t.Fatalf("BuildKoreanNameEmailBase() = %q, want %q", base, "cyhan")
}
}
func TestBuildKoreanNameEmailBaseNeedsReviewForUnknownName(t *testing.T) {
base, needsReview, err := BuildKoreanNameEmailBase("A치영")
if err != nil {
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
}
if base != "" {
t.Fatalf("BuildKoreanNameEmailBase() base = %q, want empty", base)
}
if !needsReview {
t.Fatalf("BuildKoreanNameEmailBase() needsReview = false")
}
}
func TestMatchesSuggestedNameRule(t *testing.T) {
tests := []struct {
localPart string
base string
want bool
}{
{localPart: "cyhan", base: "cyhan", want: true},
{localPart: "cyhan1", base: "cyhan", want: true},
{localPart: "cyhan20", base: "cyhan", want: true},
{localPart: "hcy", base: "cyhan", want: false},
{localPart: "han.cy", base: "cyhan", want: false},
{localPart: "cyhan-a", base: "cyhan", want: false},
}
for _, tt := range tests {
t.Run(tt.localPart, func(t *testing.T) {
if got := MatchesSuggestedNameRule(tt.localPart, tt.base); got != tt.want {
t.Fatalf("MatchesSuggestedNameRule(%q, %q) = %v, want %v", tt.localPart, tt.base, got, tt.want)
}
})
}
}

View File

@@ -11,6 +11,8 @@ const (
MetadataHeadlessJWKSURI = "headless_jwks_uri"
MetadataHeadlessJWKS = "headless_jwks"
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
MetadataAutoLoginSupported = "auto_login_supported"
MetadataAutoLoginURL = "auto_login_url"
)
type HydraClient struct {

View File

@@ -0,0 +1,16 @@
package domain
import "time"
type RPUserMetadata struct {
ClientID string `gorm:"column:client_id;primaryKey" json:"clientId"`
UserID string `gorm:"column:user_id;type:uuid;primaryKey" json:"userId"`
User *User `gorm:"foreignKey:UserID" json:"-"`
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (RPUserMetadata) TableName() string {
return "rp_user_metadata"
}

View File

@@ -10,8 +10,8 @@ import (
// TenantDomain represents a domain associated with a tenant for auto-assignment.
type TenantDomain struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"`
Domain string `gorm:"uniqueIndex;not null" json:"domain"` // e.g. "example.com"
TenantID string `gorm:"type:uuid;not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"tenantId"`
Domain string `gorm:"not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"domain"` // e.g. "example.com"
Verified bool `gorm:"default:false" json:"verified"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`