Files
agent-desk-ng/internal/oidcclient/oidcclient.go

434 lines
12 KiB
Go

package oidcclient
import (
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"strings"
"sync"
"time"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
const (
StateTTL = 5 * time.Minute
LoginTicketTTL = 1 * time.Minute
defaultLoginNextPath = "/dashboard"
)
var (
oidcMu sync.Mutex
oidcCfg config.OIDCConfig
provider *gooidc.Provider
oauthConfig *oauth2.Config
idTokenVerifier *gooidc.IDTokenVerifier
loginTicketStore sync.Map
)
type OrganizationClaim struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
}
type Profile struct {
Subject string `json:"sub"`
Email string `json:"email,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Organizations []OrganizationClaim `json:"organizations,omitempty"`
RawProfile string `json:"-"`
}
type statePayload struct {
Next string `json:"next"`
Nonce string `json:"nonce"`
Verifier string `json:"verifier,omitempty"`
ExpiredAt int64 `json:"expiredAt"`
}
type loginTicket struct {
Response *response.LoginResponse
ExpiredAt time.Time
}
func Init(ctx context.Context) error {
oidcMu.Lock()
defer oidcMu.Unlock()
provider = nil
oauthConfig = nil
idTokenVerifier = nil
oidcCfg = config.OIDCConfig{}
cfg := config.Current().OIDC
if !cfg.Enabled {
return nil
}
oidcCfg = cfg
if strings.TrimSpace(cfg.Issuer) == "" {
return i18nx.Errorf("error.e0039")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return i18nx.Errorf("error.e0036")
}
if strings.TrimSpace(cfg.ClientSecret) == "" {
return i18nx.Errorf("error.e0037")
}
if strings.TrimSpace(cfg.RedirectURL) == "" {
return i18nx.Errorf("error.e0040")
}
if ctx == nil {
ctx = context.Background()
}
connCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
p, err := gooidc.NewProvider(connCtx, strings.TrimSpace(cfg.Issuer))
if err != nil {
return err
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{gooidc.ScopeOpenID, "profile", "email"}
}
provider = p
oauthConfig = &oauth2.Config{
ClientID: strings.TrimSpace(cfg.ClientID),
ClientSecret: strings.TrimSpace(cfg.ClientSecret),
Endpoint: p.Endpoint(),
RedirectURL: strings.TrimSpace(cfg.RedirectURL),
Scopes: scopes,
}
idTokenVerifier = p.Verifier(&gooidc.Config{ClientID: strings.TrimSpace(cfg.ClientID)})
return nil
}
func ensureInitialized(ctx context.Context) {
if !Enabled() && config.Current().OIDC.Enabled {
_ = Init(ctx)
}
}
func Enabled() bool {
oidcMu.Lock()
defer oidcMu.Unlock()
return oidcCfg.Enabled && provider != nil && oauthConfig != nil && idTokenVerifier != nil
}
func BuildAuthCodeURL(next string) (string, error) {
ensureInitialized(context.Background())
if !Enabled() {
return "", errorsx.BusinessErrorI18n(1, "error.oidc.loginDisabled")
}
rawVerifier := make([]byte, 32)
if _, err := rand.Read(rawVerifier); err != nil {
return "", err
}
verifier := base64.RawURLEncoding.EncodeToString(rawVerifier)
h := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(h[:])
state, err := CreateState(next, verifier)
if err != nil {
return "", err
}
oidcMu.Lock()
defer oidcMu.Unlock()
return oauthConfig.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
), nil
}
func ExchangeCode(ctx context.Context, code string, verifier string) (*Profile, error) {
ensureInitialized(ctx)
if !Enabled() {
return nil, errorsx.BusinessErrorI18n(1, "error.oidc.loginDisabled")
}
code = strings.TrimSpace(code)
if code == "" {
return nil, errorsx.InvalidParamI18n("error.e0041")
}
oidcMu.Lock()
idVerifier := idTokenVerifier
oauthCfg := oauthConfig
prov := provider
oidcMu.Unlock()
var opts []oauth2.AuthCodeOption
if strings.TrimSpace(verifier) != "" {
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", strings.TrimSpace(verifier)))
}
token, err := oauthCfg.Exchange(ctx, code, opts...)
if err != nil {
return nil, err
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || strings.TrimSpace(rawIDToken) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0038")
}
idToken, err := idVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
profile, err := profileFromIDToken(idToken)
if err != nil {
return nil, err
}
userInfo, err := prov.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err == nil && userInfo != nil && strings.TrimSpace(userInfo.Subject) == profile.Subject {
if mergedProfile, mergeErr := profileFromUserInfo(userInfo, profile); mergeErr == nil {
profile = mergedProfile
}
}
return profile, nil
}
func CreateState(next string, verifier ...string) (string, error) {
secret := stateSecret()
if secret == "" {
return "", errorsx.BusinessErrorI18n(2, "error.oidc.stateSecretMissing")
}
nonce, err := randomToken("os_")
if err != nil {
return "", err
}
v := ""
if len(verifier) > 0 {
v = verifier[0]
}
payload := statePayload{
Next: sanitizeNextPath(next),
Nonce: nonce,
Verifier: v,
ExpiredAt: time.Now().Add(StateTTL).Unix(),
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(body)
return encoded + "." + signState(encoded, secret), nil
}
func ParseState(state string) (string, string, error) {
secret := stateSecret()
if secret == "" {
return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
parts := strings.Split(strings.TrimSpace(state), ".")
if len(parts) != 2 {
return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
if !hmac.Equal([]byte(parts[1]), []byte(signState(parts[0], secret))) {
return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
payload := statePayload{}
if err = json.Unmarshal(body, &payload); err != nil {
return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
if payload.ExpiredAt <= time.Now().Unix() {
return "", "", errorsx.UnauthorizedI18n("error.e0046")
}
return sanitizeNextPath(payload.Next), payload.Verifier, nil
}
func IssueLoginTicket(loginResp *response.LoginResponse) (string, error) {
if loginResp == nil {
return "", i18nx.Errorf("error.e0272")
}
ticket, err := randomToken("olt_")
if err != nil {
return "", err
}
cleanupExpiredLoginTickets()
loginTicketStore.Store(ticket, loginTicket{
Response: loginResp,
ExpiredAt: time.Now().Add(LoginTicketTTL),
})
return ticket, nil
}
func ConsumeLoginTicket(ticket string) (*response.LoginResponse, error) {
ticket = strings.TrimSpace(ticket)
if ticket == "" {
return nil, errorsx.InvalidParamI18n("error.e0072")
}
value, ok := loginTicketStore.LoadAndDelete(ticket)
if !ok {
return nil, errorsx.UnauthorizedI18n("error.e0271")
}
record, ok := value.(loginTicket)
if !ok || record.Response == nil || time.Now().After(record.ExpiredAt) {
return nil, errorsx.UnauthorizedI18n("error.e0271")
}
return record.Response, nil
}
func profileFromIDToken(idToken *gooidc.IDToken) (*Profile, error) {
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
raw, _ := json.Marshal(claims)
profile := &Profile{
Subject: claimString(claims, "sub"),
Email: claimString(claims, "email"),
PreferredUsername: firstNonEmpty(claimString(claims, "preferred_username"), claimString(claims, "user_name"), claimString(claims, "nickname")),
Name: firstNonEmpty(claimString(claims, "name"), claimString(claims, "full_name")),
Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")),
Organizations: claimOrganizations(claims),
RawProfile: string(raw),
}
if strings.TrimSpace(profile.Subject) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0043")
}
return profile, nil
}
func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile, error) {
var claims map[string]any
if err := userInfo.Claims(&claims); err != nil {
return nil, err
}
raw, _ := json.Marshal(claims)
profile := &Profile{
Subject: strings.TrimSpace(userInfo.Subject),
Email: firstNonEmpty(claimString(claims, "email"), userInfo.Email),
PreferredUsername: firstNonEmpty(claimString(claims, "preferred_username"), claimString(claims, "user_name"), claimString(claims, "nickname")),
Name: firstNonEmpty(claimString(claims, "name"), claimString(claims, "full_name")),
Picture: firstNonEmpty(claimString(claims, "picture"), claimString(claims, "avatar_url")),
Organizations: claimOrganizations(claims),
RawProfile: string(raw),
}
if fallback != nil {
profile.Email = firstNonEmpty(profile.Email, fallback.Email)
profile.PreferredUsername = firstNonEmpty(profile.PreferredUsername, fallback.PreferredUsername)
profile.Name = firstNonEmpty(profile.Name, fallback.Name)
profile.Picture = firstNonEmpty(profile.Picture, fallback.Picture)
if len(profile.Organizations) == 0 {
profile.Organizations = fallback.Organizations
}
}
if profile.RawProfile == "" {
if fallback != nil {
profile.RawProfile = fallback.RawProfile
}
}
if strings.TrimSpace(profile.Subject) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0043")
}
return profile, nil
}
func claimOrganizations(claims map[string]any) []OrganizationClaim {
raw, ok := claims["organizations"]
if !ok || raw == nil {
raw, ok = claims["orgs"]
}
if !ok || raw == nil {
return nil
}
bytes, err := json.Marshal(raw)
if err != nil {
return nil
}
var orgs []OrganizationClaim
if err := json.Unmarshal(bytes, &orgs); err == nil && len(orgs) > 0 {
return orgs
}
var list []map[string]any
if err := json.Unmarshal(bytes, &list); err == nil {
for _, item := range list {
id := firstNonEmpty(claimString(item, "id"), claimString(item, "org_id"), claimString(item, "code"))
name := firstNonEmpty(claimString(item, "name"), claimString(item, "org_name"), id)
role := firstNonEmpty(claimString(item, "role"), "MEMBER")
if id != "" {
orgs = append(orgs, OrganizationClaim{
ID: id,
Name: name,
Role: strings.ToUpper(role),
})
}
}
}
return orgs
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func claimString(claims map[string]any, key string) string {
value, _ := claims[key].(string)
return strings.TrimSpace(value)
}
func stateSecret() string {
if strings.TrimSpace(oidcCfg.StateSecret) != "" {
return strings.TrimSpace(oidcCfg.StateSecret)
}
return strings.TrimSpace(oidcCfg.ClientSecret)
}
func signState(content, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func cleanupExpiredLoginTickets() {
now := time.Now()
loginTicketStore.Range(func(key, value any) bool {
record, ok := value.(loginTicket)
if !ok || now.After(record.ExpiredAt) {
loginTicketStore.Delete(key)
}
return true
})
}
func sanitizeNextPath(next string) string {
next = strings.TrimSpace(next)
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return defaultLoginNextPath
}
return next
}
func randomToken(prefix string) (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return prefix + hex.EncodeToString(buf), nil
}