Add a golangci-lint config and fix its findings: the OIDC state cookie gets the Secure flag on https deployments, and pool sizes are clamped before the int32 conversion.
119 lines
3.8 KiB
Go
119 lines
3.8 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
oidcStateCookie = "oidc_state"
|
|
oidcStateTTL = 5 * time.Minute
|
|
)
|
|
|
|
// oidcLogin starts the Authorization Code flow: sets a state cookie and
|
|
// redirects the browser to the provider's authorization endpoint.
|
|
func (a *API) oidcLogin(w http.ResponseWriter, r *http.Request) {
|
|
if a.OIDCVerifier == nil {
|
|
http.Error(w, "OIDC not configured", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
state := base64.RawURLEncoding.EncodeToString(b)
|
|
|
|
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows the public URL scheme, see secureCookies
|
|
Name: oidcStateCookie,
|
|
Value: state,
|
|
Path: "/auth",
|
|
MaxAge: int(oidcStateTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: a.secureCookies(),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
http.Redirect(w, r, a.oauth2Config().AuthCodeURL(state), http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// oidcCallback receives the authorization code from the provider, exchanges it
|
|
// server-side (using the client secret), verifies the id_token, then redirects
|
|
// the browser to the SPA with the token in the URL fragment.
|
|
func (a *API) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
|
if a.OIDCVerifier == nil {
|
|
http.Error(w, "OIDC not configured", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Verify state matches the cookie we set in oidcLogin.
|
|
cookie, err := r.Cookie(oidcStateCookie)
|
|
if err != nil || cookie.Value != r.URL.Query().Get("state") {
|
|
http.Error(w, "invalid state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Consume the state cookie.
|
|
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows the public URL scheme, see secureCookies
|
|
Name: oidcStateCookie,
|
|
Value: "",
|
|
Path: "/auth",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: a.secureCookies(),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
tok, err := a.oauth2Config().Exchange(r.Context(), r.URL.Query().Get("code"))
|
|
if err != nil {
|
|
http.Error(w, "token exchange failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
idToken, _ := tok.Extra("id_token").(string)
|
|
if idToken == "" {
|
|
http.Error(w, "no id_token in provider response", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify signature and extract claims; any valid OIDC user may log in.
|
|
claims, err := a.OIDCVerifier.Verify(r.Context(), idToken)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized: "+err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Provision or update the user record on every login.
|
|
if _, err := a.Store.UpsertUser(r.Context(), claims.Subject, claims.Email, claims.Name); err != nil {
|
|
http.Error(w, "user provisioning failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Redirect to the SPA root with the token in the URL fragment.
|
|
// PublicBaseURL is set from config — never from request headers — so a
|
|
// forged Host / X-Forwarded-Host cannot redirect the token elsewhere.
|
|
dest := a.PublicBaseURL + "/#token=" + url.QueryEscape(idToken)
|
|
http.Redirect(w, r, dest, http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// oauth2Config returns an oauth2.Config using the pinned PublicBaseURL for the
|
|
// redirect_uri so it always matches what is registered in the OIDC provider.
|
|
func (a *API) oauth2Config() *oauth2.Config {
|
|
return &oauth2.Config{
|
|
ClientID: a.OIDCClientID,
|
|
ClientSecret: a.OIDCClientSecret,
|
|
RedirectURL: a.PublicBaseURL + "/auth/callback",
|
|
Scopes: []string{"openid", "profile", "email", "groups"},
|
|
Endpoint: oauth2.Endpoint{AuthURL: a.OIDCAuthURL, TokenURL: a.OIDCTokenURL},
|
|
}
|
|
}
|
|
|
|
// secureCookies reports whether cookies get the Secure flag. The public URL
|
|
// decides, so a plain http://localhost dev setup keeps working.
|
|
func (a *API) secureCookies() bool {
|
|
return strings.HasPrefix(strings.ToLower(a.PublicBaseURL), "https://")
|
|
}
|