Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ QUICKSILVER_SMTP_TIMEOUT=30s
# --- Rate limiting ---
QUICKSILVER_RATE_LIMIT_LOGIN_PER_MIN=10

# --- Google OAuth (optional) ---
# Leave CLIENT_ID empty to disable "Sign in with Google" (password login only).
# Create credentials at https://console.cloud.google.com/apis/credentials
# (OAuth consent screen + "Web application" OAuth client). The redirect URL
# below MUST be added verbatim to the client's "Authorized redirect URIs".
QUICKSILVER_GOOGLE_OAUTH_CLIENT_ID=
QUICKSILVER_GOOGLE_OAUTH_CLIENT_SECRET=
QUICKSILVER_GOOGLE_OAUTH_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
# Where the backend sends the browser (with the JWT in the URL fragment) after
# a successful sign-in. Must be a route served by the frontend SPA.
QUICKSILVER_FRONTEND_OAUTH_CALLBACK=http://localhost:3000/quicksilver/auth/callback

# --- Observability ---
QUICKSILVER_LOG_LEVEL=info
QUICKSILVER_LOG_FORMAT=json
Expand Down
9 changes: 9 additions & 0 deletions server/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"quicksilver/server/internal/config"
apihttp "quicksilver/server/internal/http"
applog "quicksilver/server/internal/log"
"quicksilver/server/internal/oauth"
"quicksilver/server/internal/session"
"quicksilver/server/internal/smtp"
)
Expand Down Expand Up @@ -61,6 +62,13 @@ func main() {
sessions := session.NewStore(bgCtx, sealer, cfg.SessionIdleTTL, cfg.SessionSweepInt, cfg.IMAPTimeout, logger)
sender := smtp.New(cfg.SMTPTimeout)

googleOAuth := oauth.NewGoogle(cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL)
if googleOAuth.Enabled() {
// Keep XOAUTH2 sessions alive across reconnects by refreshing tokens.
sessions.SetTokenRefresher(googleOAuth)
logger.Info("google oauth enabled", "redirect_url", cfg.GoogleRedirectURL)
}

router := apihttp.NewRouter(apihttp.Deps{
Config: cfg,
Logger: logger,
Expand All @@ -70,6 +78,7 @@ func main() {
Issuer: issuer,
Sealer: sealer,
Sender: sender,
OAuth: googleOAuth,
RateLimitCtx: bgCtx,
})

Expand Down
2 changes: 2 additions & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ require (
github.com/joho/godotenv v1.5.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/wneessen/go-mail v0.7.3
golang.org/x/oauth2 v0.36.0
golang.org/x/time v0.15.0
)

require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
golang.org/x/text v0.37.0 // indirect
)
4 changes: 4 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
Expand Down Expand Up @@ -25,6 +27,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
12 changes: 12 additions & 0 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ type Config struct {

RateLimitLoginPerMin int `envconfig:"RATE_LIMIT_LOGIN_PER_MIN" default:"10"`

// Google OAuth (optional). When GoogleClientID is empty, the OAuth login
// endpoints are disabled and password login is the only path.
GoogleClientID string `envconfig:"GOOGLE_OAUTH_CLIENT_ID"`
GoogleClientSecret string `envconfig:"GOOGLE_OAUTH_CLIENT_SECRET"`
// GoogleRedirectURL must exactly match an Authorized redirect URI registered
// in the Google Cloud console, e.g.
// http://localhost:3000/api/v1/auth/google/callback
GoogleRedirectURL string `envconfig:"GOOGLE_OAUTH_REDIRECT_URL"`
// FrontendOAuthCallback is the SPA URL the backend redirects to after a
// successful exchange; it receives the JWT in the URL fragment.
FrontendOAuthCallback string `envconfig:"FRONTEND_OAUTH_CALLBACK" default:"http://localhost:3000/quicksilver/auth/callback"`

TLSCert string `envconfig:"TLS_CERT"`
TLSKey string `envconfig:"TLS_KEY"`

Expand Down
2 changes: 1 addition & 1 deletion server/internal/http/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func (h *Messages) Send(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadRequest, httpx.CodeBadRequest, "to, subject, and a body are required", nil))
return
}
creds, err := sess.Credentials(h.Sealer)
creds, err := h.Sessions.FreshCredentials(r.Context(), sess)
if err != nil {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusInternalServerError, httpx.CodeInternal, "unseal credentials", err))
return
Expand Down
152 changes: 152 additions & 0 deletions server/internal/http/handlers/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package handlers

import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"log/slog"
"net/http"
"net/url"

"quicksilver/server/internal/auth"
"quicksilver/server/internal/mail"
"quicksilver/server/internal/oauth"
"quicksilver/server/internal/session"
)

// Gmail's well-known IMAP/SMTP endpoints. OAuth login is Google-only, so these
// are fixed rather than user-supplied.
const (
gmailIMAPHost = "imap.gmail.com"
gmailIMAPPort = 993
gmailSMTPHost = "smtp.gmail.com"
gmailSMTPPort = 587
)

const oauthStateCookie = "qs_oauth_state"

// OAuth bundles dependencies for the Google OAuth login flow.
type OAuth struct {
Provider *oauth.Provider
Sessions *session.Store
Issuer *auth.Issuer
FrontendCallback string // SPA URL that receives the JWT in its fragment
Logger *slog.Logger
}

// Start begins the Google OAuth flow: it sets an anti-CSRF state cookie and
// redirects the browser to Google's consent screen.
func (o *OAuth) Start(w http.ResponseWriter, r *http.Request) {
if !o.Provider.Enabled() {
http.NotFound(w, r)
return
}
state, err := randomState()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: oauthStateCookie,
Value: state,
Path: "/",
MaxAge: 600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
})
http.Redirect(w, r, o.Provider.AuthCodeURL(state), http.StatusFound)
}

// Callback completes the flow: it validates state, exchanges the code for
// tokens, resolves the user's email, opens an XOAUTH2 IMAP session, and
// redirects back to the SPA with a Quicksilver JWT in the URL fragment.
func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) {
if !o.Provider.Enabled() {
http.NotFound(w, r)
return
}

// Anti-CSRF: the state in the query must match the cookie we set in Start.
cookie, err := r.Cookie(oauthStateCookie)
qState := r.URL.Query().Get("state")
if err != nil || qState == "" ||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(qState)) != 1 {
o.fail(w, r, "invalid_state")
return
}
// Clear the state cookie now that it's been used.
http.SetCookie(w, &http.Cookie{Name: oauthStateCookie, Path: "/", MaxAge: -1})

if errParam := r.URL.Query().Get("error"); errParam != "" {
o.fail(w, r, errParam)
return
}
code := r.URL.Query().Get("code")
if code == "" {
o.fail(w, r, "missing_code")
return
}

ctx := r.Context()
tok, err := o.Provider.Exchange(ctx, code)
if err != nil {
o.Logger.Warn("oauth exchange failed", "err", err)
o.fail(w, r, "exchange_failed")
return
}
email, err := o.Provider.Email(ctx, tok)
if err != nil {
o.Logger.Warn("oauth userinfo failed", "err", err)
o.fail(w, r, "userinfo_failed")
return
}

creds := mail.Credentials{
Email: email,
IMAPHost: gmailIMAPHost,
IMAPPort: gmailIMAPPort,
IMAPSecure: true,
SMTPHost: gmailSMTPHost,
SMTPPort: gmailSMTPPort,
SMTPSecure: false, // STARTTLS on 587
AuthType: mail.AuthOAuth2,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
TokenExpiry: tok.Expiry,
}
sess, err := o.Sessions.Create(ctx, creds)
if err != nil {
o.Logger.Warn("oauth session create failed", "err", err, "email", email)
o.fail(w, r, "imap_login_failed")
return
}
token, _, err := o.Issuer.Issue(sess.ID, sess.Subject)
if err != nil {
o.Sessions.Delete(sess.ID)
o.fail(w, r, "issue_token_failed")
return
}

// Hand the JWT to the SPA via the URL fragment (never sent to a server, not
// logged in proxies/referers). The SPA reads it and stores the session.
frag := url.Values{}
frag.Set("token", token)
frag.Set("email", sess.Subject)
http.Redirect(w, r, o.FrontendCallback+"#"+frag.Encode(), http.StatusFound)
}

// fail redirects back to the SPA callback with an error code in the fragment.
func (o *OAuth) fail(w http.ResponseWriter, r *http.Request, reason string) {
frag := url.Values{}
frag.Set("error", reason)
http.Redirect(w, r, o.FrontendCallback+"#"+frag.Encode(), http.StatusFound)
}

func randomState() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
18 changes: 18 additions & 0 deletions server/internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"quicksilver/server/internal/config"
"quicksilver/server/internal/http/handlers"
"quicksilver/server/internal/http/middleware"
"quicksilver/server/internal/oauth"
"quicksilver/server/internal/session"
"quicksilver/server/internal/smtp"
)
Expand All @@ -25,6 +26,9 @@ type Deps struct {
Issuer *auth.Issuer
Sealer *session.Sealer
Sender *smtp.Sender
// OAuth is the Google OAuth provider. When nil or disabled, the OAuth login
// endpoints are not registered.
OAuth *oauth.Provider
// RateLimitCtx scopes the rate limiter's background reaper. When the
// context is cancelled, the limiter stops reaping idle IP entries.
RateLimitCtx context.Context
Expand Down Expand Up @@ -58,6 +62,20 @@ func NewRouter(d Deps) http.Handler {
})
r.With(requireSession).Post("/auth/logout", authH.Logout)

// Google OAuth login (only when configured). These are top-level
// browser navigations, not XHR, so they sit outside the JWT-guarded group.
if d.OAuth != nil && d.OAuth.Enabled() {
oauthH := &handlers.OAuth{
Provider: d.OAuth,
Sessions: d.Sessions,
Issuer: d.Issuer,
FrontendCallback: d.Config.FrontendOAuthCallback,
Logger: d.Logger,
}
r.Get("/auth/google/start", oauthH.Start)
r.Get("/auth/google/callback", oauthH.Callback)
}

// Mailboxes & messages (all require a valid session).
r.Group(func(r chi.Router) {
r.Use(requireSession)
Expand Down
7 changes: 6 additions & 1 deletion server/internal/imap/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ func (c *Client) connect(ctx context.Context) error {
}
conn.Timeout = c.timeout

if err := conn.Login(c.creds.Email, c.creds.Password); err != nil {
if c.creds.IsOAuth() {
if err := conn.Authenticate(newXOAUTH2(c.creds.Email, c.creds.AccessToken)); err != nil {
_ = conn.Logout()
return fmt.Errorf("imap xoauth2: %w", err)
}
} else if err := conn.Login(c.creds.Email, c.creds.Password); err != nil {
_ = conn.Logout()
return fmt.Errorf("imap login: %w", err)
}
Expand Down
35 changes: 35 additions & 0 deletions server/internal/imap/xoauth2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package imap

import "fmt"

// xoauth2Client implements github.com/emersion/go-sasl's Client interface for
// the XOAUTH2 mechanism, which go-sasl doesn't ship out of the box. It's the
// mechanism Gmail (and other Google Workspace) IMAP servers expect for
// OAuth2 bearer-token auth.
//
// The initial client response is the raw byte string
//
// "user=" {email} ^A "auth=Bearer " {token} ^A ^A
//
// where ^A is the 0x01 control byte. go-imap base64-encodes the response on the
// wire, so we return the raw bytes here.
type xoauth2Client struct {
username string
token string
}

func newXOAUTH2(username, token string) *xoauth2Client {
return &xoauth2Client{username: username, token: token}
}

func (a *xoauth2Client) Start() (mech string, ir []byte, err error) {
resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
return "XOAUTH2", []byte(resp), nil
}

func (a *xoauth2Client) Next(challenge []byte) ([]byte, error) {
// On an auth failure the server sends a base64 JSON error challenge and
// expects an empty client response before returning the tagged NO. Replying
// with an empty line lets go-imap surface the real error instead of hanging.
return []byte{}, nil
}
22 changes: 22 additions & 0 deletions server/internal/mail/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,22 @@ package mail

import "time"

// Auth mechanisms for upstream IMAP/SMTP. AuthPassword uses LOGIN/PLAIN with a
// password (or app password); AuthOAuth2 uses SASL XOAUTH2 with a bearer access
// token (e.g. Google). An empty AuthType is treated as AuthPassword for
// backward compatibility.
const (
AuthPassword = "password"
AuthOAuth2 = "oauth2"
)

// Credentials captures everything the server needs to connect to a user's
// upstream IMAP/SMTP provider on their behalf. SMTP credentials default to the
// same username/password as IMAP unless the SMTP* fields override them.
//
// For OAuth2 (XOAUTH2) sessions, Password is empty and the bearer token lives
// in AccessToken; RefreshToken/TokenExpiry let the server mint a fresh access
// token when the connection is re-established after the token has expired.
type Credentials struct {
Email string `json:"email"`
Password string `json:"password"`
Expand All @@ -17,8 +30,17 @@ type Credentials struct {
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPSecure bool `json:"smtp_secure"`

// OAuth2 (XOAUTH2) fields. Unused for password auth.
AuthType string `json:"auth_type,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenExpiry time.Time `json:"token_expiry,omitempty"`
}

// IsOAuth reports whether these credentials use XOAUTH2 bearer-token auth.
func (c Credentials) IsOAuth() bool { return c.AuthType == AuthOAuth2 }

// Address is a person/email pair from RFC 5322 envelope fields.
type Address struct {
Name string `json:"name,omitempty"`
Expand Down
Loading
Loading