package xguard import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) const ( loginCookiePrefix = "xguard_" loginTTL = 10 * time.Minute ) // AuthError: X-Guard mengembalikan error ke callback (mis. access_denied bila pengguna tidak diberi akses ke // aplikasi ini) atau state/nonce tidak cocok. type AuthError struct { Code string Description string } // Galat callback untuk dicek dengan errors.Is (lihat AuthError.Is). var ( // ErrLoginRequired: login senyap (Silent) tidak bisa diselesaikan tanpa interaksi, mis. sesi X-Guard sudah // berakhir (logout di aplikasi lain, akun dinonaktifkan). Akhiri sesi aplikasi, lalu login biasa ke returnTo. ErrLoginRequired = errors.New("xguard: login interaktif diperlukan") // ErrAccessDenied: X-Guard menolak, mis. pengguna tidak diberi akses ke aplikasi ini. Jangan diulang otomatis. ErrAccessDenied = errors.New("xguard: akses ditolak X-Guard") ) // Is memetakan kode error OIDC ke ErrLoginRequired / ErrAccessDenied. func (e *AuthError) Is(target error) bool { switch target { case ErrLoginRequired: switch e.Code { case "login_required", "interaction_required", "consent_required", "account_selection_required": return true } case ErrAccessDenied: return e.Code == "access_denied" } return false } func (e *AuthError) Error() string { if e.Description == "" { return "xguard: " + e.Code } return "xguard: " + e.Code + ": " + e.Description } type loginState struct { State string `json:"s"` Nonce string `json:"n"` Verifier string `json:"v"` ReturnTo string `json:"r"` Exp int64 `json:"e"` } // LoginOption mengubah permintaan login (lihat Silent). type LoginOption func(*loginOptions) type loginOptions struct{ silent bool } // Silent: login tanpa interaksi (prompt=none). Bila sesi X-Guard ada dan pengguna // boleh masuk, pengguna langsung kembali ke aplikasi tanpa layar apa pun. Bila tidak, HandleCallback mengembalikan galat yang cocok // dengan ErrLoginRequired (lanjutkan StartLogin biasa ke returnTo yang sama) // atau ErrAccessDenied. func Silent() LoginOption { return func(o *loginOptions) { o.silent = true } } // StartLogin mengarahkan browser ke halaman login X-Guard. returnTo adalah path // internal aplikasi tujuan setelah login (selain path diabaikan, jadi "/"). // Beberapa tab login bersamaan aman: cookie dibedakan per state. func (c *Client) StartLogin(w http.ResponseWriter, r *http.Request, returnTo string, options ...LoginOption) { var lo loginOptions for _, o := range options { o(&lo) } st := loginState{ State: randomString(), Nonce: randomString(), Verifier: oauth2.GenerateVerifier(), ReturnTo: safePath(returnTo), Exp: time.Now().Add(loginTTL).Unix(), } http.SetCookie(w, &http.Cookie{ Name: loginCookiePrefix + st.State[:16], Value: c.sign(st), Path: "/", MaxAge: int(loginTTL.Seconds()), HttpOnly: true, Secure: c.secure, SameSite: http.SameSiteLaxMode, }) opts := []oauth2.AuthCodeOption{oidc.Nonce(st.Nonce), oauth2.S256ChallengeOption(st.Verifier)} if len(c.cfg.Audience) > 0 { opts = append(opts, oauth2.SetAuthURLParam("audience", strings.Join(c.cfg.Audience, " "))) } if lo.silent { opts = append(opts, oauth2.SetAuthURLParam("prompt", "none")) } u := c.oauth.AuthCodeURL(st.State, opts...) http.Redirect(w, r, u, http.StatusFound) } // HandleCallback memproses redirect dari X-Guard: cek state, tukar code, dan // verifikasi ID token (signature RS256, iss, aud, exp, nonce). returnTo = // path yang diberikan ke StartLogin, juga saat X-Guard mengembalikan error // (?error=), supaya aplikasi bisa melanjutkan ke tujuan yang sama. Error dari // X-Guard hanya dipercaya bila state-nya cocok; selain itu invalid_state. func (c *Client) HandleCallback(w http.ResponseWriter, r *http.Request) (id *Identity, returnTo string, err error) { q := r.URL.Query() state := q.Get("state") if len(state) < 16 { return nil, "/", &AuthError{Code: "invalid_state", Description: "state tidak ada"} } name := loginCookiePrefix + state[:16] ck, err := r.Cookie(name) if err != nil { return nil, "/", &AuthError{Code: "invalid_state", Description: "sesi login kedaluwarsa, ulangi login"} } http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: c.secure, SameSite: http.SameSiteLaxMode}) var st loginState if !c.verify(ck.Value, &st) || st.State != state || time.Now().Unix() > st.Exp { return nil, "/", &AuthError{Code: "invalid_state", Description: "state tidak cocok atau kedaluwarsa"} } if e := q.Get("error"); e != "" { return nil, st.ReturnTo, &AuthError{Code: e, Description: q.Get("error_description")} } ctx := c.ctx(r.Context()) tok, err := c.oauth.Exchange(ctx, q.Get("code"), oauth2.VerifierOption(st.Verifier)) if err != nil { return nil, st.ReturnTo, fmt.Errorf("xguard: tukar code: %w", err) } raw, _ := tok.Extra("id_token").(string) if raw == "" { return nil, st.ReturnTo, errors.New("xguard: respons token tanpa id_token") } idt, err := c.idVerifier.Verify(ctx, raw) if err != nil { return nil, st.ReturnTo, fmt.Errorf("xguard: verifikasi id_token: %w", err) } if idt.Nonce != st.Nonce { return nil, st.ReturnTo, &AuthError{Code: "invalid_nonce", Description: "nonce tidak cocok"} } var cl struct { Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` AMR []string `json:"amr"` SID string `json:"sid"` AuthTime int64 `json:"auth_time"` } if err := idt.Claims(&cl); err != nil { return nil, st.ReturnTo, fmt.Errorf("xguard: baca klaim: %w", err) } // Organisasi dari access token (JWT X-Guard). Tidak wajib: aplikasi satu organisasi sudah tahu issuer-nya. var org string if at, err := c.jwtVerify.Verify(ctx, tok.AccessToken); err == nil { var ac struct { Org string `json:"org"` } if at.Claims(&ac) == nil { org = ac.Org } } return &Identity{ Subject: idt.Subject, Email: strings.ToLower(cl.Email), EmailVerified: cl.EmailVerified, Name: cl.Name, Org: org, AMR: cl.AMR, SessionID: cl.SID, AuthTime: time.Unix(cl.AuthTime, 0), IDToken: raw, Token: tok, }, st.ReturnTo, nil } // LogoutURL: arahkan browser ke sini untuk mengakhiri sesi X-Guard juga. // idTokenHint = Identity.IDToken saat login. Tanpa hint, X-Guard tidak bisa // mengembalikan user ke PostLogoutRedirectURL (berakhir di halaman X-Guard). func (c *Client) LogoutURL(idTokenHint string) string { if c.endSession == "" { return "" } u, _ := url.Parse(c.endSession) if idTokenHint != "" { q := url.Values{"id_token_hint": {idTokenHint}} if c.cfg.PostLogoutRedirectURL != "" { q.Set("post_logout_redirect_uri", c.cfg.PostLogoutRedirectURL) q.Set("state", randomString()) } u.RawQuery = q.Encode() } return u.String() } func (c *Client) sign(v any) string { b, _ := json.Marshal(v) p := base64.RawURLEncoding.EncodeToString(b) m := hmac.New(sha256.New, c.cookieKey) m.Write([]byte(p)) return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)) } func (c *Client) verify(s string, v any) bool { p, sig, ok := strings.Cut(s, ".") if !ok { return false } m := hmac.New(sha256.New, c.cookieKey) m.Write([]byte(p)) want := base64.RawURLEncoding.EncodeToString(m.Sum(nil)) if !hmac.Equal([]byte(sig), []byte(want)) { return false } b, err := base64.RawURLEncoding.DecodeString(p) return err == nil && json.Unmarshal(b, v) == nil } func randomString() string { b := make([]byte, 32) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // safePath hanya menerima path internal ("/..."), bukan URL lain (open redirect). func safePath(p string) string { if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") { return "/" } return p }