Files
localmodelroutex/backend/internal/api/oidclogin.go
LovroG05 4d57318e86 ci: run tests, lint and the UI build on every push
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.
2026-10-04 17:32:44 +02:00

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://")
}