diff --git a/server/.env.example b/server/.env.example index c1274be..5a9cbdd 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 359af97..3e2b21a 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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" ) @@ -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, @@ -70,6 +78,7 @@ func main() { Issuer: issuer, Sealer: sealer, Sender: sender, + OAuth: googleOAuth, RateLimitCtx: bgCtx, }) diff --git a/server/go.mod b/server/go.mod index 57d3bd0..64b21d3 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 ) diff --git a/server/go.sum b/server/go.sum index d16fb02..b81e2a0 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= @@ -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= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index ddb3a02..697cbc9 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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"` diff --git a/server/internal/http/handlers/messages.go b/server/internal/http/handlers/messages.go index e4d639e..4d058bf 100644 --- a/server/internal/http/handlers/messages.go +++ b/server/internal/http/handlers/messages.go @@ -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 diff --git a/server/internal/http/handlers/oauth.go b/server/internal/http/handlers/oauth.go new file mode 100644 index 0000000..077177e --- /dev/null +++ b/server/internal/http/handlers/oauth.go @@ -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 +} diff --git a/server/internal/http/router.go b/server/internal/http/router.go index caec548..440623b 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -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" ) @@ -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 @@ -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) diff --git a/server/internal/imap/client.go b/server/internal/imap/client.go index 73be2d7..1fa3c86 100644 --- a/server/internal/imap/client.go +++ b/server/internal/imap/client.go @@ -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) } diff --git a/server/internal/imap/xoauth2.go b/server/internal/imap/xoauth2.go new file mode 100644 index 0000000..1a548f3 --- /dev/null +++ b/server/internal/imap/xoauth2.go @@ -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 +} diff --git a/server/internal/mail/types.go b/server/internal/mail/types.go index 408e1eb..c3878f2 100644 --- a/server/internal/mail/types.go +++ b/server/internal/mail/types.go @@ -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"` @@ -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"` diff --git a/server/internal/oauth/google.go b/server/internal/oauth/google.go new file mode 100644 index 0000000..59f19ee --- /dev/null +++ b/server/internal/oauth/google.go @@ -0,0 +1,139 @@ +// Package oauth implements the server-side Google OAuth2 flow used to +// authenticate users without a password: the gateway runs the authorization +// code exchange and hands the resulting access token to IMAP/SMTP over SASL +// XOAUTH2 (see internal/imap and internal/smtp). +// +// The provider is optional. When no client ID is configured it reports +// Enabled()==false and the HTTP handlers return 404, leaving password login as +// the only path. +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// Scopes requested from Google: +// - https://mail.google.com/ grants IMAP/SMTP access (required for XOAUTH2). +// - userinfo.email + openid let us read the signed-in address for the session. +var scopes = []string{ + "https://mail.google.com/", + "https://www.googleapis.com/auth/userinfo.email", + "openid", +} + +const userInfoURL = "https://www.googleapis.com/oauth2/v3/userinfo" + +// Provider wraps a configured Google OAuth2 client. +type Provider struct { + cfg *oauth2.Config + enabled bool +} + +// NewGoogle builds a provider from OAuth client credentials. If clientID is +// empty the provider is disabled (Enabled() == false) and all methods that need +// the client return an error. +func NewGoogle(clientID, clientSecret, redirectURL string) *Provider { + if clientID == "" || clientSecret == "" || redirectURL == "" { + return &Provider{enabled: false} + } + return &Provider{ + enabled: true, + cfg: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: google.Endpoint, + }, + } +} + +// Enabled reports whether Google OAuth is configured. +func (p *Provider) Enabled() bool { return p.enabled } + +// AuthCodeURL returns the Google consent URL for the given anti-CSRF state. +// AccessTypeOffline + prompt=consent ensures Google returns a refresh token so +// long-lived sessions can mint fresh access tokens. +func (p *Provider) AuthCodeURL(state string) string { + return p.cfg.AuthCodeURL(state, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("prompt", "consent"), + ) +} + +// Exchange swaps an authorization code for tokens. +func (p *Provider) Exchange(ctx context.Context, code string) (*oauth2.Token, error) { + if !p.enabled { + return nil, errors.New("oauth not configured") + } + return p.cfg.Exchange(ctx, code) +} + +// Email fetches the signed-in user's email address from Google's userinfo +// endpoint using the access token. +func (p *Provider) Email(ctx context.Context, tok *oauth2.Token) (string, error) { + client := p.cfg.Client(ctx, tok) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, userInfoURL, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("userinfo: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("userinfo: unexpected status %d", resp.StatusCode) + } + var body struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decode userinfo: %w", err) + } + if body.Email == "" { + return "", errors.New("userinfo returned no email") + } + return body.Email, nil +} + +// FreshToken returns a valid access token, transparently refreshing via the +// refresh token if the current access token has expired. It reports whether the +// token actually changed so callers can re-persist it. Implements the +// session.TokenRefresher interface. +func (p *Provider) FreshToken( + ctx context.Context, + accessToken, refreshToken string, + expiry time.Time, +) (newAccess, newRefresh string, newExpiry time.Time, changed bool, err error) { + if !p.enabled { + return "", "", time.Time{}, false, errors.New("oauth not configured") + } + prev := &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + Expiry: expiry, + } + // TokenSource refreshes lazily: if prev is still valid it's returned as-is; + // otherwise the refresh token is used to obtain a new access token. + fresh, err := p.cfg.TokenSource(ctx, prev).Token() + if err != nil { + return "", "", time.Time{}, false, fmt.Errorf("refresh token: %w", err) + } + // Google may omit the refresh token on refresh responses — keep the old one. + rt := fresh.RefreshToken + if rt == "" { + rt = refreshToken + } + changed = fresh.AccessToken != accessToken + return fresh.AccessToken, rt, fresh.Expiry, changed, nil +} diff --git a/server/internal/session/store.go b/server/internal/session/store.go index e83b409..f60d7d2 100644 --- a/server/internal/session/store.go +++ b/server/internal/session/store.go @@ -80,6 +80,14 @@ func (s *Session) Credentials(sealer *Sealer) (mail.Credentials, error) { return sealer.Open(sealed) } +// TokenRefresher mints a fresh OAuth2 access token from a stored token, +// refreshing via the refresh token when the access token has expired. It +// reports whether the token changed so the store can re-seal it. Implemented by +// internal/oauth.Provider. +type TokenRefresher interface { + FreshToken(ctx context.Context, accessToken, refreshToken string, expiry time.Time) (newAccess, newRefresh string, newExpiry time.Time, changed bool, err error) +} + // Store holds active sessions in process memory. type Store struct { sealer *Sealer @@ -87,11 +95,35 @@ type Store struct { sweepInterval time.Duration imapTimeout time.Duration logger *slog.Logger + tokens TokenRefresher // nil unless OAuth is configured mu sync.RWMutex sessions map[string]*Session } +// SetTokenRefresher installs the OAuth token refresher used to keep XOAUTH2 +// sessions alive across reconnects. Call once at startup before serving. +func (s *Store) SetTokenRefresher(t TokenRefresher) { s.tokens = t } + +// refreshOAuth refreshes c's access token in place when c is an OAuth session +// and a refresher is configured. Returns whether the token changed. The caller +// is responsible for re-sealing the session when it reports true. +func (s *Store) refreshOAuth(ctx context.Context, c *mail.Credentials) (bool, error) { + if !c.IsOAuth() || s.tokens == nil { + return false, nil + } + access, refresh, expiry, changed, err := s.tokens.FreshToken(ctx, c.AccessToken, c.RefreshToken, c.TokenExpiry) + if err != nil { + return false, err + } + if changed { + c.AccessToken = access + c.RefreshToken = refresh + c.TokenExpiry = expiry + } + return changed, nil +} + // NewStore constructs a Store and starts the idle-eviction sweeper. // Cancel ctx to stop sweeping; remaining sessions will be closed. func NewStore(ctx context.Context, sealer *Sealer, idleTTL, sweepInterval, imapTimeout time.Duration, logger *slog.Logger) *Store { @@ -173,6 +205,15 @@ func (s *Store) IMAPFor(ctx context.Context, sess *Session) (*imap.Client, error if err != nil { return nil, fmt.Errorf("unseal creds: %w", err) } + // For OAuth sessions the stored access token may have expired since the last + // connection; refresh it (and re-seal) before reconnecting. + if changed, err := s.refreshOAuth(ctx, &creds); err != nil { + return nil, err + } else if changed { + if resealed, err := s.sealer.Seal(creds); err == nil { + sess.sealed = resealed + } + } c, err := imap.New(ctx, creds, s.imapTimeout, s.logger) if err != nil { return nil, err @@ -181,6 +222,27 @@ func (s *Store) IMAPFor(ctx context.Context, sess *Session) (*imap.Client, error return c, nil } +// FreshCredentials unseals the session's credentials, refreshing the OAuth +// access token if needed (and re-sealing the session). Use this for the SMTP +// send path so XOAUTH2 always presents a valid bearer token. Callers must not +// retain the returned struct. +func (s *Store) FreshCredentials(ctx context.Context, sess *Session) (mail.Credentials, error) { + sess.mu.Lock() + defer sess.mu.Unlock() + creds, err := s.sealer.Open(sess.sealed) + if err != nil { + return mail.Credentials{}, fmt.Errorf("unseal creds: %w", err) + } + if changed, err := s.refreshOAuth(ctx, &creds); err != nil { + return mail.Credentials{}, err + } else if changed { + if resealed, err := s.sealer.Seal(creds); err == nil { + sess.sealed = resealed + } + } + return creds, nil +} + // Delete removes the session and closes its IMAP connection. func (s *Store) Delete(id string) { s.mu.Lock() @@ -259,13 +321,21 @@ func validateCreds(c mail.Credentials) error { switch { case c.Email == "": return errors.New("email is required") - case c.Password == "": - return errors.New("password is required") case c.IMAPHost == "" || c.IMAPPort == 0: return errors.New("imap host and port are required") case c.SMTPHost == "" || c.SMTPPort == 0: return errors.New("smtp host and port are required") } + // OAuth sessions authenticate with a bearer token instead of a password. + if c.IsOAuth() { + if c.AccessToken == "" { + return errors.New("oauth access token is required") + } + return nil + } + if c.Password == "" { + return errors.New("password is required") + } return nil } diff --git a/server/internal/smtp/sender.go b/server/internal/smtp/sender.go index e25aa5d..9074768 100644 --- a/server/internal/smtp/sender.go +++ b/server/internal/smtp/sender.go @@ -88,11 +88,19 @@ func (s *Sender) Send(ctx context.Context, creds hmail.Credentials, msg hmail.Ou } } + // XOAUTH2 carries the bearer access token in the password slot; password + // auth uses PLAIN with the user's (app) password. + authType := mail.SMTPAuthPlain + secret := creds.Password + if creds.IsOAuth() { + authType = mail.SMTPAuthXOAUTH2 + secret = creds.AccessToken + } opts := []mail.Option{ mail.WithPort(creds.SMTPPort), mail.WithUsername(creds.Email), - mail.WithPassword(creds.Password), - mail.WithSMTPAuth(mail.SMTPAuthPlain), + mail.WithPassword(secret), + mail.WithSMTPAuth(authType), mail.WithTimeout(s.timeout), } if creds.SMTPSecure { diff --git a/src/App.tsx b/src/App.tsx index 8c5ee8c..31beee3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { DataProvider } from "./nonview/core/DataContext"; // Import page components import LoginPage from "./view/pages/LoginPage"; +import OAuthCallbackPage from "./view/pages/OAuthCallbackPage"; import RegisterPage from "./view/pages/RegisterPage"; import ForgotPasswordPage from "./view/pages/ForgotPasswordPage"; import InboxPage from "./view/pages/InboxPage"; @@ -29,6 +30,7 @@ function App() { {/* Public Routes */} } /> + } /> } /> } /> diff --git a/src/nonview/core/AuthContext.tsx b/src/nonview/core/AuthContext.tsx index 80ffa1a..437be51 100644 --- a/src/nonview/core/AuthContext.tsx +++ b/src/nonview/core/AuthContext.tsx @@ -53,6 +53,9 @@ interface AuthContextValue { loading: boolean; apiClient: APIClient; login: (email: string, password: string) => Promise; + // Completes a Google OAuth sign-in: the backend has already issued the JWT + // and authenticated IMAP/SMTP via XOAUTH2; we just persist the session. + loginWithOAuth: (token: string, email: string) => CurrentUser; register: (data: RegistrationData) => Promise; logout: () => Promise; resetPassword: (email: string) => Promise<{ message: string }>; @@ -198,6 +201,27 @@ export const AuthProvider: React.FC = ({ children }) => { [performLogin], ); + const loginWithOAuth = useCallback( + (token: string, email: string): CurrentUser => { + // OAuth login is Google-only; the IMAP/SMTP endpoints are fixed and the + // password is never held (XOAUTH2 bearer tokens live server-side). + const profile: MailProfile = { + name: email, + email, + emailServiceProvider: "gmail-oauth", + emailAddress: email, + imapHost: "imap.gmail.com", + imapPort: 993, + imapSecure: true, + smtpHost: "smtp.gmail.com", + smtpPort: 587, + smtpSecure: false, + }; + return persistSession(token, profile); + }, + [persistSession], + ); + const register = useCallback( async (data: RegistrationData): Promise => { const profile: MailProfile = { @@ -261,6 +285,7 @@ export const AuthProvider: React.FC = ({ children }) => { loading, apiClient, login, + loginWithOAuth, register, logout, resetPassword, diff --git a/src/view/pages/LoginPage.tsx b/src/view/pages/LoginPage.tsx index d8f346f..2dae3f4 100644 --- a/src/view/pages/LoginPage.tsx +++ b/src/view/pages/LoginPage.tsx @@ -1,7 +1,9 @@ import React, { useState } from "react"; -import { Box, Container, Typography, Link } from "@mui/material"; +import { Box, Container, Typography, Link, Button, Divider } from "@mui/material"; +import GoogleIcon from "@mui/icons-material/Google"; import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useAuth } from "../../nonview/core/AuthContext"; +import { defaultBaseURL } from "../../nonview/api/client"; import LoginForm from "../moles/LoginForm"; function LoginPage() { @@ -21,6 +23,13 @@ function LoginPage() { } }; + // Hand off to the backend's server-side OAuth flow. This is a full-page + // navigation (not XHR): backend → Google consent → backend callback → + // /auth/callback with the JWT in the fragment. + const handleGoogleSignIn = () => { + window.location.href = `${defaultBaseURL()}/api/v1/auth/google/start`; + }; + return ( + or + + + (null); + // Guard against React 18 StrictMode double-invoking the effect in dev. + const handled = useRef(false); + + useEffect(() => { + if (handled.current) return; + handled.current = true; + + const frag = window.location.hash.replace(/^#/, ""); + const params = new URLSearchParams(frag); + const errCode = params.get("error"); + const token = params.get("token"); + const email = params.get("email"); + + // Clear the fragment so the token isn't left in the address bar / history. + window.history.replaceState(null, "", window.location.pathname); + + if (errCode) { + setError(oauthErrorMessage(errCode)); + return; + } + if (!token || !email) { + setError("Sign-in response was incomplete. Please try again."); + return; + } + loginWithOAuth(token, email); + navigate("/inbox", { replace: true }); + }, [loginWithOAuth, navigate]); + + return ( + + + {error ? ( + <> + Sign-in failed + + {error} + + navigate("/login", { replace: true })} + > + Back to sign in + + + ) : ( + <> + + + Completing sign-in… + + + )} + + + ); +} + +function oauthErrorMessage(code: string): string { + switch (code) { + case "invalid_state": + return "The sign-in session expired or was tampered with. Please try again."; + case "access_denied": + return "You declined access. Sign-in was cancelled."; + case "imap_login_failed": + return "Google authorised the app, but connecting to Gmail failed. Make sure IMAP is enabled in your Gmail settings."; + case "exchange_failed": + case "userinfo_failed": + case "issue_token_failed": + case "missing_code": + return "Something went wrong while completing sign-in. Please try again."; + default: + return `Sign-in error: ${code}`; + } +} + +export default OAuthCallbackPage;