From 284667e95ca38149503c158e846be0f18566d156 Mon Sep 17 00:00:00 2001 From: mr Date: Wed, 1 Apr 2026 17:16:18 +0200 Subject: [PATCH] Forward For WS --- conf/config.go | 10 +- controllers/oauth2.go | 257 +++++++++++++++--- go.mod | 1 + go.sum | 2 + .../auth_connector/auth_connector.go | 26 +- .../auth_connector/hydra_connector.go | 216 ++++++++++++++- infrastructure/claims/claims.go | 87 +++++- infrastructure/claims/hydra_claims.go | 24 +- .../perms_connectors/keto_connector.go | 9 +- main.go | 4 +- 10 files changed, 570 insertions(+), 66 deletions(-) diff --git a/conf/config.go b/conf/config.go index 2ebe747..7ed4cf1 100644 --- a/conf/config.go +++ b/conf/config.go @@ -30,12 +30,16 @@ type Config struct { PermissionConnectorPort string PermissionConnectorAdminPort string - // OAuthRedirectURI is the registered OAuth2 redirect_uri (frontend callback URL). - // After a successful login, Hydra redirects here with the authorization code. - // The original protected URL is passed as the state parameter. AdminOrigin string Origin string + // OAuth2ClientID is the client_id registered in Hydra, used to initiate the authorization flow. + OAuth2ClientID string + // OAuth2AdminClientID is the client_id for the admin frontend. + OAuth2AdminClientID string + + // OAuthRedirectURI is the registered OAuth2 redirect_uri (frontend login/callback URL). + // Hydra redirects here with login_challenge (login phase) or authorization code (callback phase). OAuthRedirectURI string OAdminAuthRedirectURI string diff --git a/controllers/oauth2.go b/controllers/oauth2.go index 4273e12..b079595 100644 --- a/controllers/oauth2.go +++ b/controllers/oauth2.go @@ -5,12 +5,14 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "oc-auth/conf" "oc-auth/infrastructure" auth_connectors "oc-auth/infrastructure/auth_connector" "regexp" "strconv" "strings" + "sync" "time" oclib "cloud.o-forge.io/core/oc-lib" @@ -20,6 +22,48 @@ import ( beego "github.com/beego/beego/v2/server/web" ) +var selfPeerCache struct { + sync.RWMutex + peer *model.Peer + fetchedAt time.Time +} + +const selfPeerCacheTTL = 60 * time.Second + +func getCachedSelfPeer() *model.Peer { + selfPeerCache.RLock() + if selfPeerCache.peer != nil && time.Since(selfPeerCache.fetchedAt) < selfPeerCacheTTL { + p := selfPeerCache.peer + selfPeerCache.RUnlock() + return p + } + selfPeerCache.RUnlock() + + pp := oclib.NewRequestAdmin(oclib.LibDataEnum(oclib.PEER), nil).Search( + &dbs.Filters{ + Or: map[string][]dbs.Filter{ + "relation": {{Operator: dbs.EQUAL.String(), Value: peer.SELF}}, + }, + }, strconv.Itoa(peer.SELF.EnumIndex()), false) + if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" { + return nil + } + p := pp.Data[0].(*model.Peer) + + selfPeerCache.Lock() + selfPeerCache.peer = p + selfPeerCache.fetchedAt = time.Now() + selfPeerCache.Unlock() + return p +} + +// InvalidateSelfPeerCache forces the next call to getCachedSelfPeer to re-fetch from DB. +func InvalidateSelfPeerCache() { + selfPeerCache.Lock() + selfPeerCache.peer = nil + selfPeerCache.Unlock() +} + // OAuthController handles OAuth2 login/consent provider endpoints type OAuthController struct { beego.Controller @@ -28,6 +72,7 @@ type OAuthController struct { // @Title GetLogin // @Description Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists. // @Param login_challenge query string true "The login challenge from Hydra" +// @Param client_id query string true "The targetted client_id from Hydra" // @Param redirect query string true "explicit redirect by passed" // @Success 200 {object} auth_connectors.LoginChallenge @@ -37,9 +82,27 @@ type OAuthController struct { func (o *OAuthController) GetLogin() { logger := oclib.GetLogger() challenge := o.Ctx.Input.Query("login_challenge") + clientID := o.Ctx.Input.Query("client_id") if challenge == "" { - o.Ctx.ResponseWriter.WriteHeader(400) - o.Data["json"] = map[string]string{"error": "missing login_challenge parameter"} + // No challenge yet — initiate the OAuth2 flow server-side to get one from Hydra. + // This supports thick clients that cannot follow browser redirects. + freshChallenge, err := infrastructure.GetAuthConnector().InitiateLogin(clientID, "") + if err != nil { + logger.Error().Msg("Failed to initiate login: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + loginChallenge, err := infrastructure.GetAuthConnector().GetLoginChallenge(freshChallenge) + if err != nil { + logger.Error().Msg("Failed to get fresh login challenge: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + o.Data["json"] = loginChallenge o.ServeJSON() return } @@ -76,8 +139,6 @@ func (o *OAuthController) GetLogin() { o.Data["json"] = redirect o.ServeJSON() return - - return } // Return challenge info so frontend can render login form o.Data["json"] = loginChallenge @@ -86,7 +147,7 @@ func (o *OAuthController) GetLogin() { // @Title PostLogin // @Description Authenticate user via LDAP and accept Hydra login challenge -// @Param redirect query string true "explicit redirect by passed" +// @Param return_mode query string false "Return mode: 'redirect' (default, 303), 'json' (full object), 'token' (access token string)" // @Param body body auth_connectors.LoginRequest true "Login credentials and challenge" // @Success 200 {object} auth_connectors.Redirect @@ -95,7 +156,10 @@ func (o *OAuthController) GetLogin() { // @router /login [post] func (o *OAuthController) Login() { logger := oclib.GetLogger() - red := o.Ctx.Input.Query("redirect") + returnMode := o.Ctx.Input.Query("return_mode") + if returnMode == "" { + returnMode = "redirect" + } var req auth_connectors.LoginRequest if err := json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &req); err != nil { @@ -112,6 +176,13 @@ func (o *OAuthController) Login() { return } + if req.LoginChallenge == "" { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "login_challenge is required in non-local mode"} + o.ServeJSON() + return + } + // Authenticate via LDAP ldap := auth_connectors.New() found, err := ldap.Authenticate(o.Ctx.Request.Context(), req.Username, req.Password) @@ -140,6 +211,12 @@ func (o *OAuthController) Login() { ExpiresIn: 3600, AccessToken: "localtoken." + base64.StdEncoding.EncodeToString(b), } + if returnMode == "token" { + o.Ctx.ResponseWriter.Header().Set("Content-Type", "text/plain") + o.Ctx.ResponseWriter.WriteHeader(200) + o.Ctx.ResponseWriter.Write([]byte(token.AccessToken)) + return + } o.Data["json"] = token } else { o.Ctx.ResponseWriter.WriteHeader(401) @@ -149,13 +226,6 @@ func (o *OAuthController) Login() { return } - if req.LoginChallenge == "" { - o.Ctx.ResponseWriter.WriteHeader(400) - o.Data["json"] = map[string]string{"error": "login_challenge is required in non-local mode"} - o.ServeJSON() - return - } - // Accept the login challenge with Hydra redirect, err := infrastructure.GetAuthConnector().AcceptLogin(req.LoginChallenge, req.Username) if err != nil { @@ -166,13 +236,28 @@ func (o *OAuthController) Login() { return } - // Return redirect_to so the frontend follows the OAuth2 flow - if red == "false" { - o.Data["json"] = redirect + // Return according to requested mode + switch returnMode { + case "token", "json": + tokenResp, err := completeFlowToToken(redirect.RedirectTo, req.Username, req.LoginChallenge) + if err != nil { + logger.Error().Msg("Failed to complete OAuth2 flow: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + if returnMode == "token" { + o.Ctx.ResponseWriter.Header().Set("Content-Type", "text/plain") + o.Ctx.ResponseWriter.WriteHeader(200) + o.Ctx.ResponseWriter.Write([]byte(tokenResp.AccessToken)) + return + } + o.Data["json"] = tokenResp o.ServeJSON() - return + default: // "redirect" + o.Redirect(redirect.RedirectTo, 303) } - o.Redirect(redirect.RedirectTo, 303) } // @Title Consent @@ -210,7 +295,6 @@ func (o *OAuthController) Consent() { "relation": {{Operator: dbs.EQUAL.String(), Value: peer.SELF}}, }, }, strconv.Itoa(peer.SELF.EnumIndex()), false) - fmt.Println(pp.Err, pp.Data) if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" { logger.Error().Msg("Self peer not found") o.Ctx.ResponseWriter.WriteHeader(500) @@ -427,7 +511,10 @@ var whitelist = []string{ // @router /forward [get] func (o *OAuthController) InternalAuthForward() { fmt.Println("InternalAuthForward") - uri := o.Ctx.Request.Header.Get("X-Forwarded-Uri") + uri := o.Ctx.Request.Header.Get("X-Replaced-Path") + if uri == "" { + uri = o.Ctx.Request.Header.Get("X-Forwarded-Uri") + } for _, w := range whitelist { if strings.Contains(uri, w) { fmt.Println("WHITELIST", w) @@ -439,6 +526,13 @@ func (o *OAuthController) InternalAuthForward() { origin, publicKey, external := o.extractOrigin(o.Ctx.Request) reqToken := o.Ctx.Request.Header.Get("Authorization") + if reqToken == "" { + // WebSocket upgrade: the browser cannot send custom headers, so the token + // is passed as the Sec-WebSocket-Protocol subprotocol value instead. + if proto := o.Ctx.Request.Header.Get("Sec-WebSocket-Protocol"); proto != "" { + reqToken = "Bearer " + proto + } + } if reqToken == "" { // Step 1: no token — allow oc-auth's own challenge endpoints (no token needed). // No token and not a whitelisted path → restart OAuth2 flow. @@ -456,23 +550,33 @@ func (o *OAuthController) InternalAuthForward() { } reqToken = splitToken[1] - // Step 3: resolve the calling peer — only our own peer (SELF) is authorized. - // A non-SELF or unknown peer is a network/config issue, not a login problem → 401. - if external || origin == "" || publicKey == "" { - fmt.Println("Unauthorized", external, origin, publicKey) + // Step 3: verify the token belongs to our self peer. + // Decode the JWT payload and extract ext.peer_id, then compare against the cached self peer UUID. + // A mismatch means the request comes from a foreign peer → 401 (not a login problem). + tokenPeerID := extractPeerIDFromToken(reqToken) + selfPeer := getCachedSelfPeer() + fmt.Println("TOKEN", tokenPeerID, selfPeer.UUID) + if selfPeer == nil || tokenPeerID != selfPeer.UUID { o.Ctx.ResponseWriter.WriteHeader(http.StatusUnauthorized) return } - // Step 4: introspect via Hydra then check permissions via Keto. // 401 → token inactive/invalid, user must re-authenticate → restart OAuth2 flow. // 403 → token valid, but permissions denied → forbidden. // 200 → all good, let Traefik forward to the target route. - switch infrastructure.GetAuthConnector().CheckAuthForward( + introspection, permissionKey, code := infrastructure.GetAuthConnector().CheckAuthForward( reqToken, publicKey, origin, o.Ctx.Request.Header.Get("X-Forwarded-Method"), - uri, external) { + uri, external) + switch code { case http.StatusOK: + user, _, _ := oclib.ExtractTokenInfo(*o.Ctx.Request) + claims := infrastructure.GetClaims().BuildConsentSession(conf.GetConfig().OAuth2ClientID, user, selfPeer) + if !claims.EqualClaims(introspection, permissionKey) { + fmt.Println("Token is not fresh or compromised") + o.Ctx.ResponseWriter.WriteHeader(http.StatusConflict) + return + } fmt.Println("OK") o.Ctx.ResponseWriter.WriteHeader(http.StatusOK) case http.StatusForbidden: @@ -486,17 +590,27 @@ func (o *OAuthController) InternalAuthForward() { } // redirectToLogin redirects the client to Hydra's authorization endpoint to start a fresh -// OAuth2 flow. The original request URL is passed as the state parameter so the frontend -// can redirect back after successful authentication. +// OAuth2 flow. Hydra will generate a login_challenge and redirect to the configured login URL. func (o *OAuthController) redirectToLogin(origin string) { cfg := conf.GetConfig() + var clientID, redirectURI string if strings.Contains(origin, cfg.AdminOrigin) { - o.Ctx.ResponseWriter.Header().Set("Location", cfg.OAdminAuthRedirectURI) + clientID = cfg.OAuth2AdminClientID + redirectURI = cfg.OAdminAuthRedirectURI } else { - o.Ctx.ResponseWriter.Header().Set("Location", cfg.OAuthRedirectURI) + clientID = cfg.OAuth2ClientID + redirectURI = cfg.OAuthRedirectURI } + hydraAuthURL := fmt.Sprintf("http://%s:%d/oauth2/auth?client_id=%s&response_type=code&redirect_uri=%s&scope=openid", + cfg.AuthConnectPublicHost, + cfg.AuthConnectorPort, + url.QueryEscape(clientID), + url.QueryEscape(redirectURI), + ) + + o.Ctx.ResponseWriter.Header().Set("Location", hydraAuthURL) o.Ctx.ResponseWriter.WriteHeader(http.StatusFound) } @@ -589,6 +703,87 @@ func ExtractClient(request http.Request) string { return "" } +// completeFlowToToken drives the server-side OAuth2 flow after AcceptLogin. +// It follows Hydra's redirect to grab the consent_challenge, accepts it, +// then exchanges the resulting auth code for a token. +func completeFlowToToken(loginRedirectTo string, subject string, loginChallenge string) (*auth_connectors.TokenResponse, error) { + connector := infrastructure.GetAuthConnector() + + // Step 1: follow the login redirect to get the consent_challenge (uses CSRF cookie from InitiateLogin) + consentChallenge, err := connector.FollowToConsentChallenge(loginRedirectTo, loginChallenge) + if err != nil { + return nil, fmt.Errorf("consent challenge: %w", err) + } + + // Step 2: fetch consent challenge details (scopes + client_id) + consentDetails, err := connector.GetConsentChallenge(consentChallenge) + if err != nil { + return nil, fmt.Errorf("get consent challenge: %w", err) + } + + clientID := "" + if consentDetails.Client != nil { + if cid, ok := consentDetails.Client["client_id"].(string); ok { + clientID = cid + } + } + + // Step 3: get self peer for claims + pp := oclib.NewRequestAdmin(oclib.LibDataEnum(oclib.PEER), nil).Search( + &dbs.Filters{ + Or: map[string][]dbs.Filter{ + "relation": {{Operator: dbs.EQUAL.String(), Value: peer.SELF}}, + }, + }, strconv.Itoa(peer.SELF.EnumIndex()), false) + if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" { + return nil, fmt.Errorf("self peer not found") + } + p := pp.Data[0].(*model.Peer) + + // Step 4: accept consent + session := infrastructure.GetClaims().BuildConsentSession(clientID, subject, p) + consentRedirect, err := connector.AcceptConsent(consentChallenge, consentDetails.RequestedScope, session) + if err != nil { + return nil, fmt.Errorf("accept consent: %w", err) + } + + // Step 5: follow consent redirect to exchange auth code for token (uses CSRF cookie, cleans up jar) + token, err := connector.ExchangeCodeForToken(consentRedirect.RedirectTo, clientID, loginChallenge) + if err != nil { + return nil, fmt.Errorf("exchange code: %w", err) + } + return token, nil +} + +// extractPeerIDFromToken decodes the JWT payload and returns ext.peer_id. +func extractPeerIDFromToken(token string) string { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "" + } + payload := parts[1] + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + b, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return "" + } + var claims map[string]interface{} + if err := json.Unmarshal(b, &claims); err != nil { + return "" + } + ext, ok := claims["ext"].(map[string]interface{}) + if !ok { + return "" + } + peerID, _ := ext["peer_id"].(string) + return peerID +} + // extractBearerToken extracts the token from the Authorization header func extractBearerToken(r *http.Request) string { reqToken := r.Header.Get("Authorization") diff --git a/go.mod b/go.mod index f5474c8..182e405 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.1 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 // indirect github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect github.com/goraz/onion v0.1.3 // indirect diff --git a/go.sum b/go.sum index 5a2f003..27eb081 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/infrastructure/auth_connector/auth_connector.go b/infrastructure/auth_connector/auth_connector.go index 7130bc3..46b80de 100644 --- a/infrastructure/auth_connector/auth_connector.go +++ b/infrastructure/auth_connector/auth_connector.go @@ -12,6 +12,9 @@ type AuthConnector interface { Status() tools.State // Login/Consent Provider endpoints (Hydra redirects here) + // InitiateLogin starts a new OAuth2 flow server-side and returns the login_challenge + // generated by Hydra. Useful for thick clients that cannot follow browser redirects. + InitiateLogin(clientID string, redirectURI string) (string, error) GetLoginChallenge(challenge string) (*LoginChallenge, error) AcceptLogin(challenge string, subject string) (*Redirect, error) RejectLogin(challenge string, reason string) (*Redirect, error) @@ -27,12 +30,21 @@ type AuthConnector interface { RevokeToken(token string, clientID string) error RefreshToken(refreshToken string, clientID string) (*TokenResponse, error) + // Server-side flow completion (for thick clients that cannot follow browser redirects) + // FollowToConsentChallenge follows the redirect_to from AcceptLogin to extract the consent_challenge. + // loginChallenge is used to replay the CSRF cookie set during InitiateLogin. + FollowToConsentChallenge(redirectTo string, loginChallenge string) (string, error) + // ExchangeCodeForToken follows the redirect_to from AcceptConsent, extracts the auth code, + // and exchanges it for a token at Hydra's token endpoint. + // loginChallenge is used to replay the CSRF cookie and is cleaned up after use. + ExchangeCodeForToken(redirectTo string, clientID string, loginChallenge string) (*TokenResponse, error) + // CheckAuthForward validates the token and permissions for a forward auth request. // Returns an HTTP status code: // 200 — token active and permissions granted // 401 — token missing, invalid, or inactive → caller should redirect to login // 403 — token valid but permissions denied → caller should return forbidden - CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) int + CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) (*claims.Claims, string, int) } // Token is the unified token response returned to clients @@ -60,12 +72,12 @@ type Redirect struct { // LoginChallenge contains the details of a Hydra login challenge type LoginChallenge struct { - Skip bool `json:"skip"` - Subject string `json:"subject"` - Challenge string `json:"challenge"` - Client map[string]interface{} `json:"client"` - RequestURL string `json:"request_url"` - SessionID string `json:"session_id"` + Skip bool `json:"skip"` + Subject string `json:"subject"` + Challenge string `json:"challenge"` + Client map[string]interface{} `json:"client"` + RequestURL string `json:"request_url"` + SessionID string `json:"session_id"` } // LogoutChallenge contains the details of a Hydra logout challenge diff --git a/infrastructure/auth_connector/hydra_connector.go b/infrastructure/auth_connector/hydra_connector.go index 3186a4c..31ae76a 100644 --- a/infrastructure/auth_connector/hydra_connector.go +++ b/infrastructure/auth_connector/hydra_connector.go @@ -1,15 +1,19 @@ package auth_connectors import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "oc-auth/conf" "oc-auth/infrastructure/claims" "strings" + "sync" oclib "cloud.o-forge.io/core/oc-lib" "cloud.o-forge.io/core/oc-lib/models/peer" @@ -17,7 +21,8 @@ import ( ) type HydraConnector struct { - Caller *tools.HTTPCaller + Caller *tools.HTTPCaller + cookieJars sync.Map // map[loginChallenge] *cookiejar.Jar } func (h *HydraConnector) Status() tools.State { @@ -59,6 +64,59 @@ func (h *HydraConnector) getPath(isAdmin bool, isOauth bool) string { return "http://" + host + ":" + port + oauth } +// InitiateLogin starts a new OAuth2 authorization flow with Hydra server-side. +// It calls Hydra's /oauth2/auth endpoint without following the redirect, then extracts +// the login_challenge from the Location header. For thick clients that cannot follow +// browser redirects. +func (h *HydraConnector) InitiateLogin(clientID string, redirectURI string) (string, error) { + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + return "", fmt.Errorf("failed to generate state: %w", err) + } + state := hex.EncodeToString(stateBytes) + + params := fmt.Sprintf("client_id=%s&response_type=code&scope=openid&state=%s", + url.QueryEscape(clientID), state) + if redirectURI != "" { + params += "&redirect_uri=" + url.QueryEscape(redirectURI) + } + authURL := h.getPath(false, false) + "/oauth2/auth?" + params + + jar, _ := cookiejar.New(nil) + client := &http.Client{ + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse // do not follow redirects + }, + } + + resp, err := client.Get(authURL) + if err != nil { + return "", fmt.Errorf("failed to initiate login with Hydra: %w", err) + } + defer resp.Body.Close() + + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("hydra did not return a redirect location (status %d)", resp.StatusCode) + } + + parsed, err := url.Parse(location) + if err != nil { + return "", fmt.Errorf("failed to parse redirect location: %w", err) + } + + challenge := parsed.Query().Get("login_challenge") + if challenge == "" { + return "", fmt.Errorf("login_challenge not found in redirect location: %s", location) + } + + // Save the cookie jar so server-side flow completion can reuse the CSRF cookie + h.cookieJars.Store(challenge, jar) + + return challenge, nil +} + // GetLoginChallenge retrieves login challenge details from Hydra admin API func (h *HydraConnector) GetLoginChallenge(challenge string) (*LoginChallenge, error) { logger := oclib.GetLogger() @@ -192,10 +250,10 @@ func (h *HydraConnector) GetConsentChallenge(challenge string) (*ConsentChalleng func (h *HydraConnector) AcceptConsent(challenge string, grantScope []string, session claims.Claims) (*Redirect, error) { logger := oclib.GetLogger() body := map[string]interface{}{ - "grant_scope": grantScope, + "grant_scope": grantScope, "grant_access_token_audience": grantScope, // grant requested audience - "remember": true, - "remember_for": 3600, + "remember": true, + "remember_for": 3600, "session": map[string]interface{}{ "access_token": session.Session.AccessToken, "id_token": session.Session.IDToken, @@ -303,14 +361,14 @@ func (h *HydraConnector) RefreshToken(refreshToken string, clientID string) (*To // It introspects the token via Hydra then checks permissions via Keto. // Only requests from our own peer (external == false) are accepted. // Returns 200 (OK), 401 (token inactive/invalid → redirect to login), or 403 (permission denied). -func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) int { +func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) (*claims.Claims, string, int) { if forward == "" || method == "" { - return http.StatusUnauthorized + return nil, "", http.StatusUnauthorized } // Defense in depth: only SELF peer requests are allowed. - if external { + /*if external { return http.StatusUnauthorized - } + }*/ logger := oclib.GetLogger() // Introspect the token via Hydra. @@ -320,7 +378,7 @@ func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, hos if err != nil { logger.Error().Msg("Forward auth introspect failed: " + err.Error()) } - return http.StatusUnauthorized + return nil, "", http.StatusUnauthorized } // Build session claims from Hydra's introspection "ext" field. @@ -345,15 +403,147 @@ func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, hos // Check permissions via Keto. // A valid token with insufficient permissions → 403 (authenticated, not authorized). - ok, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, sessionClaims, publicKey, external) + ok, permKey, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, sessionClaims, publicKey, external) if err != nil { logger.Error().Msg("Failed to decode claims in forward auth: " + err.Error()) - return http.StatusForbidden + return nil, "", http.StatusForbidden } if !ok { - return http.StatusForbidden + return nil, "", http.StatusForbidden } - return http.StatusOK + return &sessionClaims, permKey, http.StatusOK +} + +// FollowToConsentChallenge follows the redirect_to returned by AcceptLogin. +// Hydra redirects once to the consent URL — this extracts the consent_challenge from it. +// loginChallenge is used to retrieve the CSRF cookie jar saved during InitiateLogin. +func (h *HydraConnector) FollowToConsentChallenge(redirectTo string, loginChallenge string) (string, error) { + // The redirect_to URL uses the public host (via reverse proxy). + // Rewrite it to hit Hydra directly using its internal address. + internalURL, err := rewriteToInternalHydra(h, redirectTo) + if err != nil { + return "", fmt.Errorf("failed to rewrite redirect URL: %w", err) + } + var jar http.CookieJar + if v, ok := h.cookieJars.Load(loginChallenge); ok { + jar = v.(*cookiejar.Jar) + } + client := &http.Client{ + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(internalURL) + if err != nil { + return "", fmt.Errorf("failed to follow login redirect: %w", err) + } + defer resp.Body.Close() + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("no redirect location after following login redirect (status %d)", resp.StatusCode) + } + parsed, err := url.Parse(location) + if err != nil { + return "", fmt.Errorf("failed to parse consent redirect: %w", err) + } + challenge := parsed.Query().Get("consent_challenge") + if challenge == "" { + return "", fmt.Errorf("consent_challenge not found in redirect: %s", location) + } + return challenge, nil +} + +// ExchangeCodeForToken follows the redirect_to returned by AcceptConsent to extract the +// authorization code, then exchanges it for a token at Hydra's token endpoint. +// loginChallenge is used to retrieve the CSRF cookie jar and clean it up after use. +func (h *HydraConnector) ExchangeCodeForToken(redirectTo string, clientID string, loginChallenge string) (*TokenResponse, error) { + internalURL, err := rewriteToInternalHydra(h, redirectTo) + if err != nil { + return nil, fmt.Errorf("failed to rewrite redirect URL: %w", err) + } + var jar http.CookieJar + if v, ok := h.cookieJars.Load(loginChallenge); ok { + jar = v.(*cookiejar.Jar) + } + client := &http.Client{ + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(internalURL) + if err != nil { + return nil, fmt.Errorf("failed to follow consent redirect: %w", err) + } + defer resp.Body.Close() + location := resp.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("no redirect after consent (status %d)", resp.StatusCode) + } + parsed, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("failed to parse code redirect: %w", err) + } + code := parsed.Query().Get("code") + if code == "" { + return nil, fmt.Errorf("code not found in redirect: %s", location) + } + // Reconstruct redirect_uri without query/fragment — must match the registered value + parsed.RawQuery = "" + parsed.Fragment = "" + redirectURI := parsed.String() + + cfg := conf.GetConfig() + vals := url.Values{} + vals.Add("grant_type", "authorization_code") + vals.Add("code", code) + vals.Add("client_id", clientID) + vals.Add("client_secret", cfg.ClientSecret) + vals.Add("redirect_uri", redirectURI) + resp2, err := h.Caller.CallForm(http.MethodPost, h.getPath(false, true), "/token", vals, + "application/x-www-form-urlencoded", true) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + defer resp2.Body.Close() + b, err := io.ReadAll(resp2.Body) + if err != nil { + return nil, err + } + if resp2.StatusCode >= 300 { + return nil, fmt.Errorf("token exchange failed (%s): %s", resp2.Status, string(b)) + } + var result TokenResponse + if err := json.Unmarshal(b, &result); err != nil { + return nil, err + } + // Cookie jar no longer needed — clean up + h.cookieJars.Delete(loginChallenge) + return &result, nil +} + +// rewriteToInternalHydra rewrites a public-facing Hydra URL to use the internal Hydra address. +// The redirect_to from Hydra uses the public host/port (possibly behind a reverse proxy), +// but server-side follow-ups must hit Hydra directly. +// It keeps the path suffix after "/oauth2" and the full query string. +func rewriteToInternalHydra(h *HydraConnector, publicURL string) (string, error) { + parsed, err := url.Parse(publicURL) + if err != nil { + return "", fmt.Errorf("invalid redirect URL: %w", err) + } + // Extract the path segment from "/oauth2" onward (e.g. "/oauth2/auth") + const marker = "/oauth2" + idx := strings.Index(parsed.Path, marker) + if idx < 0 { + return "", fmt.Errorf("redirect URL has no /oauth2 path segment: %s", publicURL) + } + suffix := parsed.Path[idx:] // e.g. "/oauth2/auth" + internal := h.getPath(false, false) + suffix + if parsed.RawQuery != "" { + internal += "?" + parsed.RawQuery + } + return internal, nil } // extractBearerToken extracts the token from a "Bearer xxx" Authorization header value diff --git a/infrastructure/claims/claims.go b/infrastructure/claims/claims.go index b6002e5..3695ec0 100644 --- a/infrastructure/claims/claims.go +++ b/infrastructure/claims/claims.go @@ -1,10 +1,13 @@ package claims import ( + "fmt" "oc-auth/conf" + "reflect" "strings" "cloud.o-forge.io/core/oc-lib/models/peer" + "github.com/google/go-cmp/cmp" ) // ClaimService builds and verifies OAuth2 session claims @@ -14,7 +17,7 @@ type ClaimService interface { BuildConsentSession(clientID string, userId string, peer *peer.Peer) Claims // DecodeClaimsInToken verifies permissions from claims extracted from a JWT - DecodeClaimsInToken(host string, method string, forward string, sessionClaims Claims, publicKey string, external bool) (bool, error) + DecodeClaimsInToken(host string, method string, forward string, sessionClaims Claims, publicKey string, external bool) (bool, string, error) } // SessionClaims contains access_token and id_token claim maps @@ -32,6 +35,88 @@ var t = map[string]ClaimService{ "hydra": HydraClaims{}, } +func cleanMap(m map[string]interface{}) map[string]interface{} { + if m == nil { + return map[string]interface{}{} + } + + ignored := map[string]bool{ + "exp": true, + "iat": true, + "nbf": true, + } + + out := make(map[string]interface{}) + + for k, v := range m { + if ignored[k] { + continue + } + + switch val := v.(type) { + case map[string]interface{}: + out[k] = cleanMap(val) + default: + out[k] = val + } + } + + return out +} + +func (c *Claims) EqualExt(ext map[string]interface{}) bool { + claims := &Claims{} + claims.SessionFromExt(ext) + + return c.EqualClaims(claims) +} + +func (c *Claims) EqualClaims(claims *Claims, permsKey ...string) bool { + c.normalizeClaims() + claims.normalizeClaims() + + if len(permsKey) > 0 { + for _, p := range permsKey { + if !(claims.Session.AccessToken[p] != nil && c.Session.AccessToken[p] != nil && claims.Session.AccessToken[p] == c.Session.AccessToken[p]) { + return false + } + } + return true + } + ok := reflect.DeepEqual(c.Session, claims.Session) + if !ok { + fmt.Println(cmp.Diff(c.Session, claims.Session)) + } + return ok +} + +func (c *Claims) normalizeClaims() { + c.Session.AccessToken = cleanMap(c.Session.AccessToken) + c.Session.IDToken = cleanMap(c.Session.IDToken) +} + +func (c *Claims) SessionFromExt(ext map[string]interface{}) { + var access map[string]interface{} + var id map[string]interface{} + + if v, ok := ext["access_token"].(map[string]interface{}); ok && v != nil { + access = v + } else { + access = map[string]interface{}{} + } + + if v, ok := ext["id_token"].(map[string]interface{}); ok && v != nil { + id = v + } else { + id = map[string]interface{}{} + } + + c.Session = SessionClaims{ + AccessToken: access, + IDToken: id, + } +} + func GetClaims() ClaimService { for k := range t { if strings.Contains(conf.GetConfig().Auth, k) { diff --git a/infrastructure/claims/hydra_claims.go b/infrastructure/claims/hydra_claims.go index 5cc00ff..aeab051 100644 --- a/infrastructure/claims/hydra_claims.go +++ b/infrastructure/claims/hydra_claims.go @@ -81,22 +81,21 @@ func (h HydraClaims) clearBlank(path []string) []string { } // DecodeClaimsInToken verifies permissions from claims in a standard JWT (via introspection) -func (h HydraClaims) DecodeClaimsInToken(host string, method string, forward string, sessionClaims Claims, publicKey string, external bool) (bool, error) { +func (h HydraClaims) DecodeClaimsInToken(host string, method string, forward string, sessionClaims Claims, publicKey string, external bool) (bool, string, error) { logger := oclib.GetLogger() idTokenClaims := sessionClaims.Session.IDToken // Signature verification: skip if signature is empty (internal requests) if sig, ok := idTokenClaims["signature"].(string); ok && sig != "" { if ok, err := h.DecodeSignature(host, sig, publicKey); !ok { - return false, err + return false, "", err } } claims := sessionClaims.Session.AccessToken if claims == nil { - return false, errors.New("no access_token claims found") + return false, "", errors.New("no access_token claims found") } - path := strings.ReplaceAll(forward, "http://"+host, "") splittedPath := h.clearBlank(strings.Split(path, "/")) @@ -105,11 +104,11 @@ func (h HydraClaims) DecodeClaimsInToken(host string, method string, forward str if !ok { continue } - match := true splittedP := h.clearBlank(strings.Split(pStr, "/")) if len(splittedP) != len(splittedPath) { continue } + match := true for i, v := range splittedP { if strings.Contains(v, ":") { // is a param continue @@ -127,11 +126,11 @@ func (h HydraClaims) DecodeClaimsInToken(host string, method string, forward str Relation: "permits" + strings.ToUpper(meth.String()), Object: pStr, } - return perms_connectors.GetPermissionConnector("").CheckPermission(perm, nil, true), nil + return perms_connectors.GetPermissionConnector("").CheckPermission(perm, nil, true), m, nil } } logger.Error().Msg("No permission found for " + method + " " + forward) - return false, errors.New("no permission found") + return false, "", errors.New("no permission found") } // BuildConsentSession builds the session payload for Hydra consent accept. @@ -162,7 +161,9 @@ func (h HydraClaims) BuildConsentSession(clientID string, userId string, p *peer return c } - c.Session.IDToken["username"] = userId + c.Session.AccessToken["peer_id"] = p.UUID + + c.Session.IDToken["user_id"] = userId c.Session.IDToken["peer_id"] = p.UUID c.Session.IDToken["client_id"] = clientID @@ -172,6 +173,13 @@ func (h HydraClaims) BuildConsentSession(clientID string, userId string, p *peer return c } c.Session.IDToken["groups"] = groups + + roles, err := perms_connectors.KetoConnector{}.GetRoleByUser(userId) + if err != nil { + logger.Error().Msg("Failed to get roles for user " + userId + ": " + err.Error()) + return c + } + c.Session.IDToken["roles"] = roles c.Session.IDToken["signature"] = sign return c } diff --git a/infrastructure/perms_connectors/keto_connector.go b/infrastructure/perms_connectors/keto_connector.go index b7d7f56..059f78e 100644 --- a/infrastructure/perms_connectors/keto_connector.go +++ b/infrastructure/perms_connectors/keto_connector.go @@ -6,6 +6,7 @@ import ( "fmt" "oc-auth/conf" "oc-auth/infrastructure/utils" + "strings" oclib "cloud.o-forge.io/core/oc-lib" "cloud.o-forge.io/core/oc-lib/tools" @@ -128,8 +129,12 @@ func (k KetoConnector) CreatePermission(permID string, relation string, internal if err != nil { return "", 422, err } - k.BindPermission("admin", permID, "permits"+meth.String()) - return k.creates(permID, "permits"+meth.String(), k.scope()) + id, code, err := k.creates(permID, "permits"+meth.String(), k.scope()) + if err != nil && !strings.Contains(err.Error(), "already exist") { + return id, code, err + } + k.BindPermission(conf.GetConfig().AdminRole, permID, "permits"+meth.String()) + return id, code, nil } func (k KetoConnector) creates(object string, relation string, subject string) (string, int, error) { diff --git a/main.go b/main.go index 3811b48..bfecd3f 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,8 @@ func main() { conf.GetConfig().Origin = o.GetStringDefault("ADMIN_ORIGIN", "http://localhost:8000") conf.GetConfig().AdminOrigin = o.GetStringDefault("ADMIN_ORIGIN", "http://localhost:8001") + conf.GetConfig().OAuth2ClientID = o.GetStringDefault("OAUTH2_CLIENT_ID", "oc-auth") + conf.GetConfig().OAuth2AdminClientID = o.GetStringDefault("OAUTH2_ADMIN_CLIENT_ID", "oc-auth-admin") conf.GetConfig().OAuthRedirectURI = o.GetStringDefault("OAUTH_REDIRECT_URI", "http://localhost:8000/l") conf.GetConfig().OAdminAuthRedirectURI = o.GetStringDefault("ADMIN_OAUTH_REDIRECT_URI", "http://localhost:8000/l") conf.GetConfig().Local = o.GetBoolDefault("LOCAL", true) @@ -110,7 +112,6 @@ func discovery() { if !strings.Contains(err.Error(), "already exist") { logger.Error().Msg("Failed to create admin role, retrying in 10s: " + err.Error()) time.Sleep(10 * time.Second) - continue } } if _, _, err := conn.BindRole(conf.GetConfig().AdminRole, "admin"); err != nil { @@ -121,6 +122,7 @@ func discovery() { json.Unmarshal(m.Payload, &resp) for k, v := range resp { for _, p := range v { + conn.DeletePermission(k, p.(string), true) if _, _, err := conn.CreatePermission(k, p.(string), true); err != nil { logger.Error().Msg("Failed to admin create permission: " + err.Error()) }