package publisher import ( "crypto/rand" "encoding/base64" "encoding/json" "net/http" "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 { 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 (s *Service) Index(w http.ResponseWriter, r *http.Request) error { userName := "Guest" isLoggedIn := false if user, err := getUserFromRequest(r); err == nil { isLoggedIn = true userName = user.Name if userName == "" { userName = user.Email } } w.Header().Set("Vary", "Cookie") err := templates.IndexPage(s.siteSettings, templates.PageSettings{ User: userName, IsLoggedIn: isLoggedIn, PageSettings: basetpl.PageSettings{ Title: "Home", }, }).Render(w) if err != nil { return ErrRenderFailure } return nil } // 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) 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: true, 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: true, 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) 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: true, 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) 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) error { w.Header().Set("Content-Type", "text/css") http.ServeFileFS(w, r, basetpl.Files, "style.css") return nil }