feat(auth): implement OIDC token exchange to get real username from Authelia
This commit is contained in:
parent
1780cea340
commit
9922349351
1 changed files with 87 additions and 32 deletions
|
|
@ -1,22 +1,21 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OIDCConfig holds the configuration for the Authelia OIDC client.
|
||||
type OIDCConfig struct {
|
||||
// Authelia's OIDC issuer URL (e.g., http://127.0.0.1:9091)
|
||||
IssuerURL string
|
||||
// Client ID registered in Authelia
|
||||
ClientID string
|
||||
// Client secret (if required)
|
||||
ClientSecret string
|
||||
// Redirect URL after OIDC login (e.g., https://sechpoint.app/auth/callback)
|
||||
RedirectURL string
|
||||
// The public-facing domain for cookie domain
|
||||
Domain string
|
||||
}
|
||||
|
||||
|
|
@ -39,12 +38,11 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
|
|||
state := generateToken(16)
|
||||
nonce := generateToken(16)
|
||||
|
||||
// Store state in a short-lived cookie for CSRF protection
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: 300, // 5 minutes
|
||||
MaxAge: 300,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
|
@ -62,18 +60,14 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Callback handles the OIDC authorization code callback from Authelia.
|
||||
// For now, this validates state and creates a session.
|
||||
// Full token exchange requires an HTTP client to Authelia's token endpoint.
|
||||
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
// Get state from cookie for CSRF check
|
||||
stateCookie, err := r.Cookie("oidc_state")
|
||||
if err != nil {
|
||||
http.Error(w, "missing state cookie", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state parameter matches
|
||||
// Get state from URL query (GET) or form body (POST form_post mode)
|
||||
// Get state from URL query (GET) or form body (POST)
|
||||
stateParam := r.URL.Query().Get("state")
|
||||
if stateParam == "" {
|
||||
r.ParseForm()
|
||||
|
|
@ -93,7 +87,7 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||
HttpOnly: true,
|
||||
})
|
||||
|
||||
// Get code from URL query (GET) or form body (POST form_post mode)
|
||||
// Get code from URL query (GET) or form body (POST)
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
code = r.Form.Get("code")
|
||||
|
|
@ -103,17 +97,11 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// TODO: Exchange code for tokens using Authelia's token endpoint.
|
||||
// For now, we create a session with the authorization code as a placeholder.
|
||||
// In production, you would:
|
||||
// 1. POST to /api/oidc/token with the code
|
||||
// 2. Validate the ID token
|
||||
// 3. Extract the user's subject (sub) claim
|
||||
// 4. Create a session with that subject
|
||||
|
||||
username := r.URL.Query().Get("sub")
|
||||
if username == "" {
|
||||
username = "authenticated-user" // placeholder until token exchange
|
||||
// Exchange code for tokens
|
||||
username, err := h.exchangeCode(code)
|
||||
if err != nil {
|
||||
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.store.CreateSession(username, 60)
|
||||
|
|
@ -127,22 +115,89 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||
Name: "nextwks_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 hour
|
||||
MaxAge: 3600,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
// Redirect to the workspace
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// exchangeCode exchanges an OIDC authorization code for an ID token
|
||||
// and extracts the username (sub claim) from it.
|
||||
func (h *OIDCHandler) exchangeCode(code 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},
|
||||
"client_secret": {h.config.ClientSecret},
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Decode JWT payload (without signature verification for now)
|
||||
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"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", fmt.Errorf("parse JWT claims: %w", err)
|
||||
}
|
||||
|
||||
if claims.Sub == "" {
|
||||
return "", fmt.Errorf("missing sub claim in id_token")
|
||||
}
|
||||
|
||||
return claims.Sub, nil
|
||||
}
|
||||
|
||||
// AuthGateMiddleware protects routes behind OIDC authentication.
|
||||
// If the user has no valid session, redirect to Authelia login.
|
||||
func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := GetUserID(r)
|
||||
if !ok {
|
||||
// Not authenticated — redirect to login
|
||||
h.LoginRedirect(w, r)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue