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,23 +1,22 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OIDCConfig holds the configuration for the Authelia OIDC client.
|
// OIDCConfig holds the configuration for the Authelia OIDC client.
|
||||||
type OIDCConfig struct {
|
type OIDCConfig struct {
|
||||||
// Authelia's OIDC issuer URL (e.g., http://127.0.0.1:9091)
|
IssuerURL string
|
||||||
IssuerURL string
|
ClientID string
|
||||||
// Client ID registered in Authelia
|
|
||||||
ClientID string
|
|
||||||
// Client secret (if required)
|
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
// Redirect URL after OIDC login (e.g., https://sechpoint.app/auth/callback)
|
RedirectURL string
|
||||||
RedirectURL string
|
Domain string
|
||||||
// The public-facing domain for cookie domain
|
|
||||||
Domain string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OIDCHandler handles OIDC authentication flows with Authelia.
|
// OIDCHandler handles OIDC authentication flows with Authelia.
|
||||||
|
|
@ -39,12 +38,11 @@ func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
state := generateToken(16)
|
state := generateToken(16)
|
||||||
nonce := generateToken(16)
|
nonce := generateToken(16)
|
||||||
|
|
||||||
// Store state in a short-lived cookie for CSRF protection
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "oidc_state",
|
Name: "oidc_state",
|
||||||
Value: state,
|
Value: state,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: 300, // 5 minutes
|
MaxAge: 300,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
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.
|
// 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) {
|
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get state from cookie for CSRF check
|
|
||||||
stateCookie, err := r.Cookie("oidc_state")
|
stateCookie, err := r.Cookie("oidc_state")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "missing state cookie", http.StatusBadRequest)
|
http.Error(w, "missing state cookie", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify state parameter matches
|
// Get state from URL query (GET) or form body (POST)
|
||||||
// Get state from URL query (GET) or form body (POST form_post mode)
|
|
||||||
stateParam := r.URL.Query().Get("state")
|
stateParam := r.URL.Query().Get("state")
|
||||||
if stateParam == "" {
|
if stateParam == "" {
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
|
|
@ -93,7 +87,7 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
HttpOnly: true,
|
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")
|
code := r.URL.Query().Get("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
code = r.Form.Get("code")
|
code = r.Form.Get("code")
|
||||||
|
|
@ -103,17 +97,11 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Exchange code for tokens using Authelia's token endpoint.
|
// Exchange code for tokens
|
||||||
// For now, we create a session with the authorization code as a placeholder.
|
username, err := h.exchangeCode(code)
|
||||||
// In production, you would:
|
if err != nil {
|
||||||
// 1. POST to /api/oidc/token with the code
|
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
// 2. Validate the ID token
|
return
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.store.CreateSession(username, 60)
|
token, err := h.store.CreateSession(username, 60)
|
||||||
|
|
@ -127,22 +115,89 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
Name: "nextwks_session",
|
Name: "nextwks_session",
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: 3600, // 1 hour
|
MaxAge: 3600,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to the workspace
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
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.
|
// 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 {
|
func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := GetUserID(r)
|
_, ok := GetUserID(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not authenticated — redirect to login
|
|
||||||
h.LoginRedirect(w, r)
|
h.LoginRedirect(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue