mount publisher underneath main website app
1 file changed, 233 insertions(+), 19 deletions(-)
changed files
M domain/content/publisher/mux.go → domain/content/publisher/mux.go
@@ -1,44 +1,53 @@ package publisher import ( - "context" + "crypto/rand" + "encoding/base64" + "encoding/json" "net/http" - "tailscale.com/tailcfg" + "golang.org/x/oauth2" "alin.ovh/homestead/domain/content/publisher/templates" basetpl "alin.ovh/homestead/domain/web/templates" ihttp "alin.ovh/homestead/shared/http" ) -type user struct{} +type user struct { + ID string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` +} + +const ( + sessionCookieName = "oidc_session" + stateCookieName = "oidc_state" + verifierCookieName = "oidc_verifier" +) var ( ErrCannotDetermineUser = ihttp.NewError("cannot determine user", http.StatusInternalServerError) ErrRenderFailure = ihttp.NewError("failed to render page", http.StatusInternalServerError) ) -func (app *App) WithUserContext(fn ihttp.HandleFunc) ihttp.HandleFunc { - return func(w http.ResponseWriter, r *http.Request) ihttp.Error { - ctx := r.Context() - who, err := app.localClient.WhoIs(ctx, r.RemoteAddr) - if err != nil { - return ErrCannotDetermineUser +func (s *Service) Index(w http.ResponseWriter, r *http.Request) ihttp.Error { + userName := "Guest" + isLoggedIn := false + if user, err := getUserFromRequest(r); err == nil { + isLoggedIn = true + userName = user.Name + if userName == "" { + userName = user.Email } - - return fn(w, r.WithContext( - context.WithValue(ctx, user{}, who.UserProfile.Clone()), - )) } -} -func (app *App) Index(w http.ResponseWriter, r *http.Request) ihttp.Error { - user := r.Context().Value(user{}).(*tailcfg.UserProfile) - err := templates.IndexPage(app.siteSettings, templates.PageSettings{ + err := templates.IndexPage(s.siteSettings, templates.PageSettings{ + User: userName, + IsLoggedIn: isLoggedIn, PageSettings: basetpl.PageSettings{ Title: "Home", }, - User: user.LoginName, }).Render(w) if err != nil { return ErrRenderFailure@@ -47,7 +56,212 @@ return nil } -func (app *App) Style(w http.ResponseWriter, r *http.Request) ihttp.Error { +// Login initiates the OIDC authentication flow with PKCE S256. +// PKCE (Proof Key for Code Exchange) protects against authorization code interception attacks +// by using a cryptographically random verifier and its SHA256 hash challenge. +// See: https://www.rfc-editor.org/rfc/rfc7636 +func (s *Service) Login(w http.ResponseWriter, r *http.Request) ihttp.Error { + // Generate random state for CSRF protection + state, err := generateRandomState() + if err != nil { + s.log.Error("failed to generate state", "error", err) + + return ihttp.NewError("failed to generate state", http.StatusInternalServerError) + } + + // Generate PKCE verifier for S256 challenge + verifier := oauth2.GenerateVerifier() + + // Store state in cookie for verification in callback + http.SetCookie(w, &http.Cookie{ + Name: stateCookieName, + Value: state, + Path: s.baseURL.Path, + MaxAge: 600, // 10 minutes + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + // Store PKCE verifier in cookie for token exchange + http.SetCookie(w, &http.Cookie{ + Name: verifierCookieName, + Value: verifier, + Path: s.baseURL.Path, + MaxAge: 600, // 10 minutes + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + // Redirect to OIDC provider with PKCE S256 challenge + url := s.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + http.Redirect(w, r, url, http.StatusFound) + + return nil +} + +// Callback handles the OIDC provider's redirect after authentication. +// It verifies the state parameter (CSRF protection), exchanges the authorization code +// for tokens using the PKCE verifier, validates the ID token, and creates a session. +func (s *Service) Callback(w http.ResponseWriter, r *http.Request) ihttp.Error { + ctx := r.Context() + + // Verify state parameter (CSRF protection) + stateCookie, err := r.Cookie(stateCookieName) + if err != nil { + s.log.Error("state cookie not found", "error", err) + + return ihttp.NewError("invalid state", http.StatusBadRequest) + } + + state := r.URL.Query().Get("state") + if state != stateCookie.Value { + s.log.Error("state mismatch") + + return ihttp.NewError("invalid state", http.StatusBadRequest) + } + + // Retrieve PKCE verifier for token exchange + verifierCookie, err := r.Cookie(verifierCookieName) + if err != nil { + s.log.Error("verifier cookie not found", "error", err) + + return ihttp.NewError("invalid verifier", http.StatusBadRequest) + } + + // Clear state cookie + http.SetCookie(w, &http.Cookie{ + Name: stateCookieName, + Value: "", + Path: s.baseURL.Path, + MaxAge: -1, + HttpOnly: true, + }) + + // Clear verifier cookie + http.SetCookie(w, &http.Cookie{ + Name: verifierCookieName, + Value: "", + Path: s.baseURL.Path, + MaxAge: -1, + HttpOnly: true, + }) + + // Exchange code for token with PKCE verifier + code := r.URL.Query().Get("code") + oauth2Token, err := s.oauth2Config.Exchange( + ctx, + code, + oauth2.VerifierOption(verifierCookie.Value), + ) + if err != nil { + s.log.Error("failed to exchange token", "error", err) + + return ihttp.NewError("failed to exchange token", http.StatusInternalServerError) + } + + // Extract ID Token from OAuth2 token + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + s.log.Error("no id_token in token response") + + return ihttp.NewError("no id_token in token response", http.StatusInternalServerError) + } + + // Verify ID Token + idToken, err := s.oidcVerifier.Verify(ctx, rawIDToken) + if err != nil { + s.log.Error("failed to verify ID token", "error", err) + + return ihttp.NewError("failed to verify ID token", http.StatusInternalServerError) + } + + // Extract user claims + var userInfo user + if err := idToken.Claims(&userInfo); err != nil { + s.log.Error("failed to parse claims", "error", err) + + return ihttp.NewError("failed to parse claims", http.StatusInternalServerError) + } + + // Store user info in session cookie + sessionData, err := json.Marshal(userInfo) + if err != nil { + s.log.Error("failed to marshal session data", "error", err) + + return ihttp.NewError("failed to create session", http.StatusInternalServerError) + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: base64.StdEncoding.EncodeToString(sessionData), + Path: s.baseURL.Path, + MaxAge: 86400 * 7, // 7 days + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + s.log.Info("user logged in", "email", userInfo.Email, "name", userInfo.Name) + + http.Redirect(w, r, s.baseURL.Path, http.StatusFound) + + return nil +} + +func (s *Service) Logout(w http.ResponseWriter, r *http.Request) ihttp.Error { + // Clear session cookie + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: s.baseURL.Path, + MaxAge: -1, + HttpOnly: true, + }) + + s.log.Info("user logged out") + + // Redirect to home page + http.Redirect(w, r, s.baseURL.Path, http.StatusFound) + + return nil +} + +// getUserFromRequest extracts and decodes user information from the session cookie. +// Returns an error if the cookie is missing or invalid. +func getUserFromRequest(r *http.Request) (*user, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, err + } + + sessionData, err := base64.StdEncoding.DecodeString(cookie.Value) + if err != nil { + return nil, err + } + + var userInfo user + if err := json.Unmarshal(sessionData, &userInfo); err != nil { + return nil, err + } + + return &userInfo, nil +} + +// generateRandomState generates a cryptographically secure random state string +// for CSRF protection in the OAuth2/OIDC flow. +func generateRandomState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(b), nil +} + +func (s *Service) Style(w http.ResponseWriter, r *http.Request) ihttp.Error { w.Header().Set("Content-Type", "text/css") http.ServeFileFS(w, r, basetpl.Files, "style.css")