NextWks/src/core/auth/oidc.go

236 lines
6.1 KiB
Go

package auth
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// OIDCConfig holds the configuration for the Authelia OIDC client.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
Domain string
}
// OIDCHandler handles OIDC authentication flows with Authelia.
type OIDCHandler struct {
config OIDCConfig
store *SessionStore
}
// NewOIDCHandler creates a new OIDC handler.
func NewOIDCHandler(config OIDCConfig, store *SessionStore) *OIDCHandler {
return &OIDCHandler{
config: config,
store: store,
}
}
// LoginRedirect redirects the user to Authelia's OIDC authorization endpoint.
func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
state := generateToken(16)
nonce := generateToken(16)
// PKCE: generate code verifier and challenge
verifier := generateToken(32)
challenge := pkceChallenge(verifier)
// Store state + verifier in cookies (shared across subdomains)
http.SetCookie(w, &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/",
Domain: h.config.Domain,
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "oidc_verifier",
Value: verifier,
Path: "/",
Domain: h.config.Domain,
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
authURL := fmt.Sprintf(
"%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256",
h.config.IssuerURL,
url.QueryEscape(h.config.ClientID),
url.QueryEscape(h.config.RedirectURL),
state,
nonce,
challenge,
)
http.Redirect(w, r, authURL, http.StatusFound)
}
// Callback handles the OIDC authorization code callback from Authelia.
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("oidc_state")
if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest)
return
}
// Get state from URL query (GET) or form body (POST)
stateParam := r.URL.Query().Get("state")
if stateParam == "" {
r.ParseForm()
stateParam = r.Form.Get("state")
}
if stateParam == "" || stateParam != stateCookie.Value {
http.Error(w, "state mismatch", http.StatusForbidden)
return
}
// Get PKCE verifier from cookie
verifierCookie, _ := r.Cookie("oidc_verifier")
verifier := ""
if verifierCookie != nil {
verifier = verifierCookie.Value
}
// Clear state cookies
http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.SetCookie(w, &http.Cookie{Name: "oidc_verifier", Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
// Get code from URL query (GET) or form body (POST)
code := r.URL.Query().Get("code")
if code == "" {
code = r.Form.Get("code")
}
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
// Exchange code for tokens (with PKCE verifier)
username, err := h.exchangeCode(code, verifier)
if err != nil {
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}
token, err := h.store.CreateSession(username, 60)
if err != nil {
http.Error(w, "session creation failed", http.StatusInternalServerError)
return
}
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "nextwks_session",
Value: token,
Path: "/",
Domain: h.config.Domain,
MaxAge: 3600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusFound)
}
// exchangeCode exchanges an OIDC authorization code for an ID token.
func (h *OIDCHandler) exchangeCode(code, verifier string) (string, error) {
tokenURL := h.config.IssuerURL + "/api/oidc/token"
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {h.config.RedirectURL},
"client_id": {h.config.ClientID},
"code_verifier": {verifier},
}
resp, err := http.PostForm(tokenURL, data)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("parse token response: %w", err)
}
if tokenResp.IDToken == "" {
return "", fmt.Errorf("no id_token in response")
}
username, err := decodeJWTSub(tokenResp.IDToken)
if err != nil {
return "", fmt.Errorf("decode id_token: %w", err)
}
return username, nil
}
// decodeJWTSub extracts the "sub" (subject/username) from a JWT without verifying the signature.
func decodeJWTSub(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode JWT payload: %w", err)
}
var claims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("parse JWT claims: %w", err)
}
// Use preferred_username (actual username), fall back to sub (UUID)
username := claims.PreferredUsername
if username == "" {
username = claims.Sub
}
if username == "" {
return "", fmt.Errorf("missing username in id_token")
}
return username, nil
}
// pkceChallenge creates a PKCE S256 challenge from a verifier.
func pkceChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// AuthGateMiddleware protects routes behind OIDC authentication.
func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := GetUserID(r)
if !ok {
h.LoginRedirect(w, r)
return
}
next.ServeHTTP(w, r)
})
}