// Package xguard menyambungkan aplikasi Go ke SSO X-Guard organisasi (OpenID Connect). // // Isi paket: // - Login: StartLogin dan HandleCallback (authorization code + PKCE S256, state dan nonce disimpan di cookie // bertanda tangan). // - Logout: LogoutURL (RP-initiated logout; semua aplikasi organisasi ikut keluar). // - Back-channel logout: BackchannelLogoutHandler / VerifyLogoutToken. // - Webhook: WebhookHandler / VerifyWebhook untuk event pengguna dari panel Webhook di konsol. // - API: RequireAccessToken / VerifyAccessToken untuk access token JWT dari X-Guard. // // Paket ini hanya menjawab "siapa pengguna ini". Penautan ke user lokal (simpan Identity.Subject), role, dan sesi // aplikasi tetap urusan aplikasi. Panduan: identity/docs/integrasi/README.md. package xguard import ( "context" "crypto/hmac" "crypto/sha256" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) type Config struct { // Issuer: alamat X-Guard organisasi, mis. https://nama-organisasi.x-guard.online atau domain sendiri. Tampil di // rincian aplikasi di konsol. Wajib. Issuer string ClientID string ClientSecret string // RedirectURL: URL callback aplikasi, persis seperti "Alamat callback" di konsol. RedirectURL string // PostLogoutRedirectURL: tujuan setelah logout (harus terdaftar sebagai "Alamat sesudah logout"). Opsional. PostLogoutRedirectURL string // Scopes tambahan. Default: openid profile email. Scopes []string // Audience: client ID API lain yang akan dipanggil dengan access token pengguna. Harus diizinkan di kolom // Audience aplikasi ini di konsol. Masuk ke klaim aud access token. Kosong = tidak diminta. Audience []string // CookieKey: kunci HMAC cookie login sementara (minimal 32 byte). // Kosong = diturunkan dari ClientSecret. CookieKey []byte // HTTPClient untuk discovery, token, dan JWKS. Kosong = http.Client 15 detik. HTTPClient *http.Client } // Identity: hasil login yang sudah diverifikasi. type Identity struct { // Subject (sub): ID pengguna di X-Guard (diawali USR_), tidak pernah berubah. Simpan dan pakai sebagai kunci // penautan user lokal; email bisa berganti. Subject string Email string EmailVerified bool Name string // Org: slug organisasi X-Guard pengguna (klaim org access token). Org string // AMR: cara login, mis. ["pwd"]. AMR []string // SessionID (sid): sesi X-Guard. Simpan di sesi aplikasi supaya back-channel logout bisa mencabut sesi yang tepat. SessionID string AuthTime time.Time // IDToken mentah, untuk id_token_hint saat logout. IDToken string // Token: access token (JWT, 5 menit), refresh token (bila scope offline_access diminta), dan kedaluwarsanya. Hanya // perlu disimpan bila aplikasi memanggil API lain atas nama pengguna; simpan terenkripsi. Refresh token dirotasi: // simpan yang baru setiap kali refresh. Token *oauth2.Token } type Client struct { cfg Config oauth oauth2.Config idVerifier *oidc.IDTokenVerifier jwtVerify *oidc.IDTokenVerifier // untuk logout token & access token endSession string cookieKey []byte secure bool httpClient *http.Client } // New membaca discovery X-Guard dan menyiapkan client. func New(ctx context.Context, cfg Config) (*Client, error) { cfg.Issuer = strings.TrimRight(cfg.Issuer, "/") if cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { return nil, errors.New("xguard: Issuer, ClientID, ClientSecret, dan RedirectURL wajib diisi") } redirect, err := url.Parse(cfg.RedirectURL) if err != nil || redirect.Host == "" { return nil, fmt.Errorf("xguard: RedirectURL tidak valid: %q", cfg.RedirectURL) } hc := cfg.HTTPClient if hc == nil { hc = &http.Client{Timeout: 15 * time.Second} } ctx = oidc.ClientContext(ctx, hc) provider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("xguard: discovery %s: %w", cfg.Issuer, err) } var meta struct { EndSession string `json:"end_session_endpoint"` } _ = provider.Claims(&meta) key := cfg.CookieKey if len(key) == 0 { m := hmac.New(sha256.New, []byte(cfg.ClientSecret)) m.Write([]byte("xguard-login-cookie")) key = m.Sum(nil) } if len(key) < 32 { return nil, errors.New("xguard: CookieKey minimal 32 byte") } scopes := append([]string{oidc.ScopeOpenID, "profile", "email"}, cfg.Scopes...) return &Client{ cfg: cfg, oauth: oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: provider.Endpoint(), RedirectURL: cfg.RedirectURL, Scopes: dedupe(scopes), }, idVerifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), jwtVerify: provider.Verifier(&oidc.Config{SkipClientIDCheck: true, SkipExpiryCheck: true}), endSession: meta.EndSession, cookieKey: key, secure: redirect.Scheme == "https", httpClient: hc, }, nil } func (c *Client) ctx(ctx context.Context) context.Context { return oidc.ClientContext(ctx, c.httpClient) } func dedupe(in []string) []string { seen := map[string]bool{} var out []string for _, s := range in { if s != "" && !seen[s] { seen[s] = true out = append(out, s) } } return out }