diff --git a/controllers/oauth2.go b/controllers/oauth2.go index b53535b..9da06be 100644 --- a/controllers/oauth2.go +++ b/controllers/oauth2.go @@ -8,8 +8,8 @@ import ( "oc-auth/conf" "oc-auth/infrastructure" auth_connectors "oc-auth/infrastructure/auth_connector" - "oc-auth/infrastructure/claims" "regexp" + "strconv" "strings" "time" @@ -19,155 +19,357 @@ import ( beego "github.com/beego/beego/v2/server/web" ) -// Operations about auth +// OAuthController handles OAuth2 login/consent provider endpoints type OAuthController struct { beego.Controller } -// @Title Logout -// @Description unauthenticate user -// @Param Authorization header string false "auth token" -// @Param client_id query string true "the client_id you want to get" -// @Success 200 {string} -// @router /logout [delete] -func (o *OAuthController) LogOut() { - // authorize user - clientID := o.Ctx.Input.Query("client_id") - reqToken := o.Ctx.Request.Header.Get("Authorization") - splitToken := strings.Split(reqToken, "Bearer ") - if len(splitToken) < 2 { - reqToken = "" - } else { - reqToken = splitToken[1] +// @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" +// @Success 200 {object} auth_connectors.LoginChallenge +// @Failure 400 missing login_challenge +// @Failure 500 internal error +// @router /login [get] +func (o *OAuthController) GetLogin() { + logger := oclib.GetLogger() + challenge := o.Ctx.Input.Query("login_challenge") + if challenge == "" { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "missing login_challenge parameter"} + o.ServeJSON() + return } - var res auth_connectors.Token - json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &res) - if !conf.GetConfig().Local { - token, err := infrastructure.GetAuthConnector().Logout(clientID, reqToken) - if err != nil || token == nil { - o.Data["json"] = err - } else { - o.Data["json"] = token + if conf.GetConfig().Local { + // In local mode, return a mock challenge for dev + o.Data["json"] = &auth_connectors.LoginChallenge{ + Skip: false, + Challenge: challenge, } - } else { - o.Data["json"] = reqToken + o.ServeJSON() + return } - o.ServeJSON() -} -// @Title Login -// @Description authenticate user -// @Param body body models.workflow true "The workflow content" -// @Param client_id query string true "the client_id you want to get" -// @Success 200 {string} -// @router /login [post] -func (o *OAuthController) Login() { - // authorize user - clientID := o.Ctx.Input.Query("client_id") - var res auth_connectors.Token - json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &res) + loginChallenge, err := infrastructure.GetAuthConnector().GetLoginChallenge(challenge) + if err != nil { + logger.Error().Msg("Failed to get login challenge: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } - if conf.GetConfig().SourceMode == "ldap" { - ldap := auth_connectors.New() - found, err := ldap.Authenticate(o.Ctx.Request.Context(), res.Username, res.Password) - fmt.Println("login", clientID, found, err) - if err != nil || !found { - o.Data["json"] = err - o.Ctx.ResponseWriter.WriteHeader(401) + // If skip is true, the user already has an active session — auto-accept + if loginChallenge.Skip { + redirect, err := infrastructure.GetAuthConnector().AcceptLogin(challenge, loginChallenge.Subject) + if err != nil { + logger.Error().Msg("Failed to auto-accept login: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} o.ServeJSON() return } + o.Data["json"] = redirect + o.ServeJSON() + return } - if !conf.GetConfig().Local { - token, err := infrastructure.GetAuthConnector().Login( - clientID, res.Username, - &http.Cookie{ // open a session - Name: "csrf_token", - Value: o.XSRFToken(), - }) - fmt.Println("login token", token, err) - if err != nil || token == nil { - o.Data["json"] = err - o.Ctx.ResponseWriter.WriteHeader(401) - } else { - o.Data["json"] = token - } - } else { + + // Return challenge info so frontend can render login form + o.Data["json"] = loginChallenge + o.ServeJSON() +} + +// @Title PostLogin +// @Description Authenticate user via LDAP and accept Hydra login challenge +// @Param body body auth_connectors.LoginRequest true "Login credentials and challenge" +// @Success 200 {object} auth_connectors.Redirect +// @Failure 401 invalid credentials +// @Failure 500 internal error +// @router /login [post] +func (o *OAuthController) Login() { + logger := oclib.GetLogger() + var req auth_connectors.LoginRequest + if err := json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &req); err != nil { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "invalid request body"} + o.ServeJSON() + return + } + + if req.Username == "" || req.Password == "" { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "username and password are required"} + o.ServeJSON() + return + } + + // Authenticate via LDAP + ldap := auth_connectors.New() + found, err := ldap.Authenticate(o.Ctx.Request.Context(), req.Username, req.Password) + if err != nil || !found { + logger.Error().Msg("LDAP authentication failed for user: " + req.Username) + o.Ctx.ResponseWriter.WriteHeader(401) + o.Data["json"] = map[string]string{"error": "invalid credentials"} + o.ServeJSON() + return + } + + if conf.GetConfig().Local { + // In local mode, return a mock token for dev t := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search( nil, fmt.Sprintf("%v", model.SELF.EnumIndex()), false) if t.Err == "" && len(t.Data) > 0 { - token := &auth_connectors.Token{ - Username: res.Username, - Password: res.Password, - TokenType: "Bearer", - Active: true, - ExpiresIn: 3600, - AccessToken: "localtoken", - } + p := t.Data[0].(*model.Peer) + c := infrastructure.GetClaims().BuildConsentSession("local", req.Username, p) now := time.Now().UTC() - now = now.Add(time.Duration(token.ExpiresIn) * time.Second) - unix := now.Unix() - c := claims.GetClaims().AddClaimsToToken(clientID, res.Username, t.Data[0].(*model.Peer)) - c.Session.AccessToken["exp"] = unix + now = now.Add(3600 * time.Second) + c.Session.AccessToken["exp"] = now.Unix() b, _ := json.Marshal(c) - token.AccessToken = token.AccessToken + "." + base64.StdEncoding.EncodeToString(b) + token := &auth_connectors.Token{ + Active: true, + TokenType: "Bearer", + ExpiresIn: 3600, + AccessToken: "localtoken." + base64.StdEncoding.EncodeToString(b), + } o.Data["json"] = token - } else { - o.Data["json"] = t.Err o.Ctx.ResponseWriter.WriteHeader(401) + o.Data["json"] = map[string]string{"error": "peer not found"} + } + o.ServeJSON() + 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 { + logger.Error().Msg("Failed to accept login: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + + // Return redirect_to so the frontend follows the OAuth2 flow + o.Data["json"] = redirect + o.ServeJSON() +} + +// @Title Consent +// @Description Hydra redirects here with a consent_challenge. Auto-accepts consent with user permissions. +// @Param consent_challenge query string true "The consent challenge from Hydra" +// @Success 200 {object} auth_connectors.Redirect +// @Failure 400 missing consent_challenge +// @Failure 500 internal error +// @router /consent [get] +func (o *OAuthController) Consent() { + logger := oclib.GetLogger() + challenge := o.Ctx.Input.Query("consent_challenge") + if challenge == "" { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "missing consent_challenge parameter"} + o.ServeJSON() + return + } + + // Get consent challenge details from Hydra + consentChallenge, err := infrastructure.GetAuthConnector().GetConsentChallenge(challenge) + if err != nil { + logger.Error().Msg("Failed to get consent challenge: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + + // Get self peer for signing + pp := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search( + nil, strconv.Itoa(peer.SELF.EnumIndex()), false) + if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" { + logger.Error().Msg("Self peer not found") + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": "self peer not found"} + o.ServeJSON() + return + } + p := pp.Data[0].(*peer.Peer) + + // Extract client_id from consent challenge + clientID := "" + if consentChallenge.Client != nil { + if cid, ok := consentChallenge.Client["client_id"].(string); ok { + clientID = cid + } + } + + // Build consent session with user permissions and claims + session := infrastructure.GetClaims().BuildConsentSession(clientID, consentChallenge.Subject, p) + + // Accept the consent challenge — grant all requested scopes + redirect, err := infrastructure.GetAuthConnector().AcceptConsent(challenge, consentChallenge.RequestedScope, session) + if err != nil { + logger.Error().Msg("Failed to accept consent: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + + // Return redirect_to (callback URL with authorization code) + o.Data["json"] = redirect + o.ServeJSON() +} + +// @Title GetLogout +// @Description Hydra redirects here with a logout_challenge. Accepts the challenge and returns a redirect URL. +// @Param logout_challenge query string true "The logout challenge from Hydra" +// @Success 200 {object} auth_connectors.Redirect +// @Failure 400 missing logout_challenge +// @Failure 500 internal error +// @router /logout [get] +func (o *OAuthController) GetLogout() { + logger := oclib.GetLogger() + challenge := o.Ctx.Input.Query("logout_challenge") + if challenge == "" { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "missing logout_challenge parameter"} + o.ServeJSON() + return + } + + if conf.GetConfig().Local { + o.Data["json"] = &auth_connectors.Redirect{RedirectTo: ""} + o.ServeJSON() + return + } + + _, err := infrastructure.GetAuthConnector().GetLogoutChallenge(challenge) + if err != nil { + logger.Error().Msg("Failed to get logout challenge: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + + redirect, err := infrastructure.GetAuthConnector().AcceptLogout(challenge) + if err != nil { + logger.Error().Msg("Failed to accept logout challenge: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + o.ServeJSON() + return + } + + o.Data["json"] = redirect + o.ServeJSON() +} + +// @Title Logout +// @Description Revoke an OAuth2 token +// @Param Authorization header string false "Bearer token" +// @Param client_id query string true "The client_id" +// @Success 200 {object} auth_connectors.Token +// @router /logout [delete] +func (o *OAuthController) LogOut() { + clientID := o.Ctx.Input.Query("client_id") + reqToken := extractBearerToken(o.Ctx.Request) + + if conf.GetConfig().Local { + o.Data["json"] = map[string]string{"status": "logged out"} + o.ServeJSON() + return + } + + err := infrastructure.GetAuthConnector().RevokeToken(reqToken, clientID) + if err != nil { + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + } else { + o.Data["json"] = &auth_connectors.Token{ + AccessToken: reqToken, + Active: false, } } o.ServeJSON() } -// @Title Introspection -// @Description introspect token -// @Param body body models.Token true "The token info" -// @Param client_id query string true "the client_id you want to get" -// @Success 200 {string} +// @Title Refresh +// @Description Exchange a refresh_token for a new token set +// @Param body body object true "refresh_token and client_id" +// @Success 200 {object} auth_connectors.TokenResponse +// @Failure 401 invalid refresh token // @router /refresh [post] func (o *OAuthController) Refresh() { - clientID := o.Ctx.Input.Query("client_id") - var token auth_connectors.Token - json.Unmarshal(o.Ctx.Input.CopyBody(100000), &token) - // refresh token - if !conf.GetConfig().Local { - newToken, err := infrastructure.GetAuthConnector().Refresh(clientID, &token) - if err != nil || newToken == nil { - o.Data["json"] = err - o.Ctx.ResponseWriter.WriteHeader(401) - } else { - newToken.ExpiresIn = 3600 - o.Data["json"] = newToken - } + logger := oclib.GetLogger() + var body struct { + RefreshToken string `json:"refresh_token"` + ClientID string `json:"client_id"` + } + json.Unmarshal(o.Ctx.Input.CopyBody(100000), &body) + + if conf.GetConfig().Local { + o.Data["json"] = map[string]string{"error": "refresh not supported in local mode"} + o.Ctx.ResponseWriter.WriteHeader(400) + o.ServeJSON() + return + } + + if body.RefreshToken == "" { + o.Ctx.ResponseWriter.WriteHeader(400) + o.Data["json"] = map[string]string{"error": "refresh_token is required"} + o.ServeJSON() + return + } + + token, err := infrastructure.GetAuthConnector().RefreshToken(body.RefreshToken, body.ClientID) + if err != nil { + logger.Error().Msg("Failed to refresh token: " + err.Error()) + o.Ctx.ResponseWriter.WriteHeader(401) + o.Data["json"] = map[string]string{"error": err.Error()} } else { - token.ExpiresIn = 3600 o.Data["json"] = token } o.ServeJSON() } -// @Title Introspection -// @Description introspect token -// @Param Authorization header string false "auth token" -// @Success 200 {string} +// @Title Introspect +// @Description Introspect a token — respects Hydra's response +// @Param Authorization header string false "Bearer token" +// @Success 200 {object} auth_connectors.IntrospectResult // @router /introspect [get] func (o *OAuthController) Introspect() { - reqToken := o.Ctx.Request.Header.Get("Authorization") - splitToken := strings.Split(reqToken, "Bearer ") - if len(splitToken) < 2 { - reqToken = "" - } else { - reqToken = splitToken[1] + reqToken := extractBearerToken(o.Ctx.Request) + if reqToken == "" { + o.Ctx.ResponseWriter.WriteHeader(401) + o.Data["json"] = map[string]string{"error": "missing bearer token"} + o.ServeJSON() + return } - if !conf.GetConfig().Local { - token, err := infrastructure.GetAuthConnector().Introspect(reqToken) - if err != nil || !token { - o.Data["json"] = err - o.Ctx.ResponseWriter.WriteHeader(401) - } + + if conf.GetConfig().Local { + o.Data["json"] = &auth_connectors.IntrospectResult{Active: true} + o.ServeJSON() + return + } + + result, err := infrastructure.GetAuthConnector().Introspect(reqToken) + if err != nil { + o.Ctx.ResponseWriter.WriteHeader(500) + o.Data["json"] = map[string]string{"error": err.Error()} + } else if !result.Active { + o.Ctx.ResponseWriter.WriteHeader(401) + o.Data["json"] = result + } else { + o.Data["json"] = result } o.ServeJSON() } @@ -176,15 +378,15 @@ var whitelist = []string{ "/login", "/refresh", "/introspect", + "/consent", } // @Title AuthForward -// @Description auth forward -// @Param Authorization header string false "auth token" +// @Description Forward auth for Traefik — validates JWT via Hydra introspection +// @Param Authorization header string false "Bearer token" // @Success 200 {string} // @router /forward [get] func (o *OAuthController) InternalAuthForward() { - fmt.Println("InternalAuthForward") reqToken := o.Ctx.Request.Header.Get("Authorization") if reqToken == "" { for _, w := range whitelist { @@ -205,7 +407,7 @@ func (o *OAuthController) InternalAuthForward() { reqToken = splitToken[1] } origin, publicKey, external := o.extractOrigin(o.Ctx.Request) - if !infrastructure.GetAuthConnector().CheckAuthForward( //reqToken != "" && + if !infrastructure.GetAuthConnector().CheckAuthForward( reqToken, publicKey, origin, o.Ctx.Request.Header.Get("X-Forwarded-Method"), o.Ctx.Request.Header.Get("X-Forwarded-Uri"), external) && origin != "" && publicKey != "" { @@ -231,13 +433,13 @@ func (o *OAuthController) extractOrigin(request *http.Request) (string, string, searchStr = strings.Replace(searchStr, t, "", -1) } pp := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), user, peerID, groups, nil).Search(nil, searchStr, false) - if pp.Code != 200 || len(pp.Data) == 0 { // TODO: add state of partnership + if pp.Code != 200 || len(pp.Data) == 0 { return "", "", external } p := pp.Data[0].(*model.Peer) publicKey = p.PublicKey origin = p.APIUrl - if origin != "" { // is external + if origin != "" { if p.Relation == peer.SELF { external = false } @@ -247,28 +449,70 @@ func (o *OAuthController) extractOrigin(request *http.Request) (string, string, return origin, publicKey, external } +// ExtractClient extracts the client_id from a JWT token. +// Supports both standard JWT (3 parts with base64 payload) and local dev tokens. func ExtractClient(request http.Request) string { reqToken := request.Header.Get("Authorization") splitToken := strings.Split(reqToken, "Bearer ") if len(splitToken) < 2 { - reqToken = "" - } else { - reqToken = splitToken[1] + return "" } - if reqToken != "" { - token := strings.Split(reqToken, ".") - if len(token) > 2 { - bytes, err := base64.StdEncoding.DecodeString(token[2]) + reqToken = splitToken[1] + if reqToken == "" { + return "" + } + + // Try to decode as standard JWT (header.payload.signature) + parts := strings.Split(reqToken, ".") + if len(parts) >= 2 { + // Decode the payload (second part of JWT) + payload := parts[1] + // Add padding if needed + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + bytes, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + // Try standard base64 for local dev tokens + bytes, err = base64.StdEncoding.DecodeString(parts[len(parts)-1]) if err != nil { return "" } - m := map[string]interface{}{} - err = json.Unmarshal(bytes, &m) - if err != nil { - return "" + } + m := map[string]interface{}{} + if err := json.Unmarshal(bytes, &m); err != nil { + return "" + } + // Standard JWT: look for client_id in top-level or ext claims + if cid, ok := m["client_id"].(string); ok { + return cid + } + if ext, ok := m["ext"].(map[string]interface{}); ok { + if cid, ok := ext["client_id"].(string); ok { + return cid + } + } + // Local dev token format: session.id_token.client_id + if session, ok := m["session"].(map[string]interface{}); ok { + if idToken, ok := session["id_token"].(map[string]interface{}); ok { + if cid, ok := idToken["client_id"].(string); ok { + return cid + } } - return m["session"].(map[string]interface{})["id_token"].(map[string]interface{})["client_id"].(string) } } return "" } + +// extractBearerToken extracts the token from the Authorization header +func extractBearerToken(r *http.Request) string { + reqToken := r.Header.Get("Authorization") + splitToken := strings.Split(reqToken, "Bearer ") + if len(splitToken) < 2 { + return "" + } + return splitToken[1] +} diff --git a/go.mod b/go.mod index d6219c3..f5474c8 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module oc-auth go 1.24.6 require ( - cloud.o-forge.io/core/oc-lib v0.0.0-20260212123952-403913d8cf13 + cloud.o-forge.io/core/oc-lib v0.0.0-20260219084344-9662ac6d678c github.com/beego/beego/v2 v2.3.1 github.com/smartystreets/goconvey v1.7.2 go.uber.org/zap v1.27.0 diff --git a/go.sum b/go.sum index 7eea0a3..5a2f003 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.o-forge.io/core/oc-lib v0.0.0-20260210081202-3bcf0da56aa1 h1:CSPqJlSepu0ef cloud.o-forge.io/core/oc-lib v0.0.0-20260210081202-3bcf0da56aa1/go.mod h1:jmyBwmsac/4V7XPL347qawF60JsBCDmNAMfn/ySXKYo= cloud.o-forge.io/core/oc-lib v0.0.0-20260212123952-403913d8cf13 h1:DNIPQ7C+7wjbj5RUx29wLxuIe/wiSOcuUMlLRIv6Fvs= cloud.o-forge.io/core/oc-lib v0.0.0-20260212123952-403913d8cf13/go.mod h1:jmyBwmsac/4V7XPL347qawF60JsBCDmNAMfn/ySXKYo= +cloud.o-forge.io/core/oc-lib v0.0.0-20260219084344-9662ac6d678c h1:brsB6se+xMv386Vf6dSu3In2QZSH4EqgcAYkI4fNpJw= +cloud.o-forge.io/core/oc-lib v0.0.0-20260219084344-9662ac6d678c/go.mod h1:jmyBwmsac/4V7XPL347qawF60JsBCDmNAMfn/ySXKYo= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= diff --git a/infrastructure/auth_connector/auth_connector.go b/infrastructure/auth_connector/auth_connector.go index 9572752..add72b9 100644 --- a/infrastructure/auth_connector/auth_connector.go +++ b/infrastructure/auth_connector/auth_connector.go @@ -1,8 +1,8 @@ package auth_connectors import ( - "net/http" "oc-auth/conf" + "oc-auth/infrastructure/claims" "strings" "cloud.o-forge.io/core/oc-lib/tools" @@ -10,31 +10,103 @@ import ( type AuthConnector interface { Status() tools.State - Login(clientID string, username string, cookies ...*http.Cookie) (*Token, error) - Logout(clientID string, token string, cookies ...*http.Cookie) (*Token, error) - Introspect(token string, cookie ...*http.Cookie) (bool, error) - Refresh(client_id string, token *Token) (*Token, error) + + // Login/Consent Provider endpoints (Hydra redirects here) + GetLoginChallenge(challenge string) (*LoginChallenge, error) + AcceptLogin(challenge string, subject string) (*Redirect, error) + RejectLogin(challenge string, reason string) (*Redirect, error) + GetConsentChallenge(challenge string) (*ConsentChallenge, error) + AcceptConsent(challenge string, grantScope []string, session claims.Claims) (*Redirect, error) + + // Logout Provider endpoints (Hydra redirects here) + GetLogoutChallenge(challenge string) (*LogoutChallenge, error) + AcceptLogout(challenge string) (*Redirect, error) + + // Token operations + Introspect(token string) (*IntrospectResult, error) + RevokeToken(token string, clientID string) error + RefreshToken(refreshToken string, clientID string) (*TokenResponse, error) + + // Forward auth CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) bool } +// Token is the unified token response returned to clients type Token struct { - Active bool `json:"active"` - AccessToken string `json:"access_token"` - ExpiresIn int64 `json:"expires_in"` - TokenType string `json:"token_type"` - - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` + Active bool `json:"active"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope,omitempty"` } +// LoginRequest is the body of POST /oc/login +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + LoginChallenge string `json:"login_challenge"` +} + +// Redirect is a response containing a redirect URL from Hydra type Redirect struct { RedirectTo string `json:"redirect_to"` } +// 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"` +} + +// LogoutChallenge contains the details of a Hydra logout challenge +type LogoutChallenge struct { + Subject string `json:"subject"` + SessionID string `json:"sid"` + RequestURL string `json:"request_url"` + RPInitiated bool `json:"rp_initiated"` +} + +// ConsentChallenge contains the details of a Hydra consent challenge +type ConsentChallenge struct { + Skip bool `json:"skip"` + Subject string `json:"subject"` + Challenge string `json:"challenge"` + RequestedScope []string `json:"requested_scope"` + RequestedAccessTokenAud []string `json:"requested_access_token_audience"` + Client map[string]interface{} `json:"client"` +} + +// TokenResponse is the OAuth2 token response from Hydra +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + Scope string `json:"scope"` +} + +// IntrospectResult is the OAuth2 introspection response from Hydra +type IntrospectResult struct { + Active bool `json:"active"` + Sub string `json:"sub,omitempty"` + ClientID string `json:"client_id,omitempty"` + Scope string `json:"scope,omitempty"` + ExpiresAt int64 `json:"exp,omitempty"` + TokenType string `json:"token_type,omitempty"` + Extra map[string]interface{} `json:"ext,omitempty"` +} + var a = map[string]AuthConnector{ - "hydra": HydraConnector{ + "hydra": &HydraConnector{ Caller: tools.NewHTTPCaller(map[tools.DataType]map[tools.METHOD]string{}), - State: "12345678", ResponseType: "token", Scopes: "openid profile email roles"}, // base url + }, } func GetAuthConnector() AuthConnector { diff --git a/infrastructure/auth_connector/hydra_connector.go b/infrastructure/auth_connector/hydra_connector.go index 1120a98..96c8962 100644 --- a/infrastructure/auth_connector/hydra_connector.go +++ b/infrastructure/auth_connector/hydra_connector.go @@ -1,7 +1,6 @@ package auth_connectors import ( - "encoding/base64" "encoding/json" "errors" "fmt" @@ -10,10 +9,7 @@ import ( "net/url" "oc-auth/conf" "oc-auth/infrastructure/claims" - "regexp" - "strconv" "strings" - "time" oclib "cloud.o-forge.io/core/oc-lib" "cloud.o-forge.io/core/oc-lib/models/peer" @@ -21,14 +17,10 @@ import ( ) type HydraConnector struct { - State string `json:"state"` - Scopes string `json:"scope"` - ResponseType string `json:"response_type"` - Caller *tools.HTTPCaller } -func (a HydraConnector) Status() tools.State { +func (h *HydraConnector) Status() tools.State { caller := tools.NewHTTPCaller(map[tools.DataType]map[tools.METHOD]string{}) var responseBody map[string]interface{} host := conf.GetConfig().AuthConnectPublicHost @@ -47,226 +39,8 @@ func (a HydraConnector) Status() tools.State { return tools.ALIVE } -// urlFormat formats the URL of the peer with the data type API function -func (a *HydraConnector) urlFormat(url string, replaceWith string) string { - // localhost is replaced by the local peer URL - // because localhost must collide on a web request security protocol - r := regexp.MustCompile("(http://[a-z]+:[0-9]+)/oauth2") - t := r.FindString(url) - if t != "" { - url = strings.Replace(url, t, replaceWith, -1) - } - return url -} - -func (a HydraConnector) challenge(username string, url string, challenge string, cookies ...*http.Cookie) (*Redirect, string, []*http.Cookie, error) { - body := map[string]interface{}{ - "remember_for": 0, - "remember": true, - } - if challenge != "consent" { - body["subject"] = username - } - s := strings.Split(url, challenge+"_challenge=") - resp, err := a.Caller.CallRaw(http.MethodPut, - a.getPath(true, true), "/auth/requests/"+challenge+"/accept?"+challenge+"_challenge="+s[1], - body, "application/json", true, cookies...) // "remember": true, "subject": username - if err != nil { - return nil, s[1], cookies, err - } - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, s[1], cookies, err - } - var token Redirect - err = json.Unmarshal(b, &token) - if err != nil { - return nil, s[1], cookies, err - } - return &token, s[1], cookies, nil -} - -func (a HydraConnector) Refresh(client_id string, token *Token) (*Token, error) { - access := strings.Split(token.AccessToken, ".") - if len(access) > 2 { - token.AccessToken = strings.Join(access[0:2], ".") - } - isValid, err := a.Introspect(token.AccessToken) - if err != nil || !isValid { - return nil, err - } - _, err = a.Logout(client_id, token.AccessToken) - if err != nil { - return nil, err - } - return a.Login(client_id, token.Username) -} - -func (a HydraConnector) tryLog(username string, url string, subpath string, challenge string, cookies ...*http.Cookie) (*Redirect, string, []*http.Cookie, error) { - resp, err := a.Caller.CallRaw(http.MethodGet, url, subpath, - map[string]interface{}{}, "application/json", true, cookies...) - if err != nil || resp.Request.Response == nil || resp.Request.Response.Header["Set-Cookie"] == nil { - return nil, "", cookies, err - } - cc := resp.Request.Response.Header["Set-Cookie"] // retrieve oauth2 csrf token cookie - if len(cc) > 0 { - for _, c := range cc { - first := strings.Split(c, ";") - cookies = append(cookies, &http.Cookie{ - Name: strings.Split(first[0], "=")[0], - Value: strings.ReplaceAll(first[0], strings.Split(first[0], "=")[0]+"=", ""), - }) - } - } - return a.challenge(username, resp.Request.URL.String(), challenge, cookies...) -} - -func (a HydraConnector) getClient(clientID string) string { - resp, err := a.Caller.CallGet(a.getPath(true, false), "/clients") - if err != nil { - fmt.Println(err) - return "" - } - var clients []interface{} - err = json.Unmarshal(resp, &clients) - if err != nil || len(clients) == 0 { - return "" - } - for _, c := range clients { - if c.(map[string]interface{})["client_name"].(string) == clientID { - return c.(map[string]interface{})["client_id"].(string) - } - } - return clients[0].(map[string]interface{})["client_id"].(string) -} - -func (a HydraConnector) Login(clientID string, username string, cookies ...*http.Cookie) (t *Token, err error) { - clientID = a.getClient(clientID) - if clientID == "" { - return nil, errors.New("no client found") - } - redirect, _, cookies, err := a.tryLog(username, a.getPath(false, true), - "/auth?client_id="+clientID+"&response_type="+strings.ReplaceAll(a.ResponseType, " ", "%20")+"&scope="+strings.ReplaceAll(a.Scopes, " ", "%20")+"&state="+a.State, - "login", cookies...) - if err != nil || redirect == nil { - if redirect == nil { - return nil, errors.New("no oauth redirection " + clientID) - } - return nil, err - } - redirect, _, cookies, err = a.tryLog(username, a.urlFormat(redirect.RedirectTo, a.getPath(false, true)), "", "consent", cookies...) - if err != nil || redirect == nil { - return nil, err - } - // problem with consent THERE we need to accept the consent challenge && get the token - _, err = a.Caller.CallRaw(http.MethodGet, a.urlFormat(redirect.RedirectTo, a.getPath(false, true)), "", map[string]interface{}{}, - "application/json", true, cookies...) - if err != nil { - s := strings.Split(err.Error(), "\"") - if len(s) > 1 && strings.Contains(s[1], "access_token") { - err = nil - } else { - return nil, err - } - } - token := &Token{ - Username: username, - } - urls := url.Values{} - urls.Add("client_id", clientID) - urls.Add("client_secret", conf.GetConfig().ClientSecret) - urls.Add("grant_type", "client_credentials") - resp, err := a.Caller.CallForm(http.MethodPost, a.getPath(false, true), "/token", urls, - "application/x-www-form-urlencoded", true, cookies...) - var m map[string]interface{} - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - fmt.Println("login", b, err, a.getPath(false, true), "/token") - if err != nil { - return nil, err - } - err = json.Unmarshal(b, &token) - fmt.Println("login2", token, err) - if err != nil { - return nil, err - } - json.Unmarshal(b, &m) - pp := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search(nil, strconv.Itoa(peer.SELF.EnumIndex()), false) - if len(pp.Data) == 0 || pp.Code >= 300 || pp.Err != "" { - fmt.Println(pp.Data, pp.Code, pp.Err, strconv.Itoa(peer.SELF.EnumIndex())) - return nil, errors.New("peer not found") - } - now := time.Now().UTC() - now = now.Add(time.Duration(token.ExpiresIn) * time.Second) - unix := now.Unix() - - c := claims.GetClaims().AddClaimsToToken(clientID, username, pp.Data[0].(*peer.Peer)) - c.Session.AccessToken["exp"] = unix - - b, _ = json.Marshal(c) - - token.AccessToken = strings.ReplaceAll(token.AccessToken, "ory_at_", "") + "." + base64.StdEncoding.EncodeToString(b) - token.Active = true - return token, nil -} - -func (a HydraConnector) Logout(clientID string, token string, cookies ...*http.Cookie) (*Token, error) { - clientID = a.getClient(clientID) - access := strings.Split(token, ".") - if len(access) > 2 { - token = strings.Join(access[0:2], ".") - } - p := a.getPath(false, true) + "/revoke" - urls := url.Values{} - urls.Add("token", token) - urls.Add("client_id", clientID) - urls.Add("client_secret", conf.GetConfig().ClientSecret) - _, err := a.Caller.CallForm(http.MethodPost, p, "", urls, "application/x-www-form-urlencoded", true) - if err != nil { - return nil, err - } - return &Token{ - AccessToken: token, - Active: false, - }, nil -} -func (a HydraConnector) Introspect(token string, cookie ...*http.Cookie) (bool, error) { - // check validity of the token by calling introspect endpoint - // if token is not active, we need to re-authenticate by sending the user to the login page - access := strings.Split(token, ".") - if len(access) > 2 { - token = strings.Join(access[0:2], ".") - } - urls := url.Values{} - urls.Add("token", token) - resp, err := a.Caller.CallForm(http.MethodPost, a.getPath(true, true), "/introspect", urls, - "application/x-www-form-urlencoded", true, cookie...) - fmt.Println(a.getPath(true, true)) - if err != nil || resp.StatusCode >= 300 { - return false, err - } - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - if err != nil { - return false, err - } - var introspect Token - err = json.Unmarshal(b, &introspect) - fmt.Println(introspect.Active, token) - if err != nil { - return false, err - } - introspect.AccessToken = token - - // temporary TODO : real oauth2 - introspect.Active = true - introspect.ExpiresIn = 3600 - - return introspect.Active, nil -} - -func (a HydraConnector) getPath(isAdmin bool, isOauth bool) string { +// getPath builds the base URL for Hydra API calls +func (h *HydraConnector) getPath(isAdmin bool, isOauth bool) string { host := conf.GetConfig().AuthConnectPublicHost if isAdmin { host = conf.GetConfig().AuthConnectorHost @@ -283,29 +57,310 @@ func (a HydraConnector) getPath(isAdmin bool, isOauth bool) string { oauth = "/oauth2" } return "http://" + host + ":" + port + oauth - } -func (a HydraConnector) CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) bool { +// GetLoginChallenge retrieves login challenge details from Hydra admin API +func (h *HydraConnector) GetLoginChallenge(challenge string) (*LoginChallenge, error) { + logger := oclib.GetLogger() + resp, err := h.Caller.CallGet(h.getPath(true, true), "/auth/requests/login?login_challenge="+url.QueryEscape(challenge)) + if err != nil { + logger.Error().Msg("Failed to get login challenge: " + err.Error()) + return nil, err + } + var result LoginChallenge + if err := json.Unmarshal(resp, &result); err != nil { + logger.Error().Msg("Failed to unmarshal login challenge: " + err.Error()) + return nil, err + } + return &result, nil +} + +// AcceptLogin accepts a login challenge after LDAP authentication +func (h *HydraConnector) AcceptLogin(challenge string, subject string) (*Redirect, error) { + logger := oclib.GetLogger() + body := map[string]interface{}{ + "subject": subject, + "remember": true, + "remember_for": 3600, + } + resp, err := h.Caller.CallRaw(http.MethodPut, + h.getPath(true, true), "/auth/requests/login/accept?login_challenge="+url.QueryEscape(challenge), + body, "application/json", true) + if err != nil { + logger.Error().Msg("Failed to accept login challenge: " + err.Error()) + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, errors.New("hydra accept login returned status " + resp.Status + ": " + string(b)) + } + var redirect Redirect + if err := json.Unmarshal(b, &redirect); err != nil { + return nil, err + } + return &redirect, nil +} + +// RejectLogin rejects a login challenge +func (h *HydraConnector) RejectLogin(challenge string, reason string) (*Redirect, error) { + logger := oclib.GetLogger() + body := map[string]interface{}{ + "error": "access_denied", + "error_description": reason, + } + resp, err := h.Caller.CallRaw(http.MethodPut, + h.getPath(true, true), "/auth/requests/login/reject?login_challenge="+url.QueryEscape(challenge), + body, "application/json", true) + if err != nil { + logger.Error().Msg("Failed to reject login challenge: " + err.Error()) + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var redirect Redirect + if err := json.Unmarshal(b, &redirect); err != nil { + return nil, err + } + return &redirect, nil +} + +// GetLogoutChallenge retrieves logout challenge details from Hydra admin API +func (h *HydraConnector) GetLogoutChallenge(challenge string) (*LogoutChallenge, error) { + logger := oclib.GetLogger() + resp, err := h.Caller.CallGet(h.getPath(true, true), "/auth/requests/logout?logout_challenge="+url.QueryEscape(challenge)) + if err != nil { + logger.Error().Msg("Failed to get logout challenge: " + err.Error()) + return nil, err + } + var result LogoutChallenge + if err := json.Unmarshal(resp, &result); err != nil { + logger.Error().Msg("Failed to unmarshal logout challenge: " + err.Error()) + return nil, err + } + return &result, nil +} + +// AcceptLogout accepts a logout challenge — invalidates the Hydra session +func (h *HydraConnector) AcceptLogout(challenge string) (*Redirect, error) { + logger := oclib.GetLogger() + resp, err := h.Caller.CallRaw(http.MethodPut, + h.getPath(true, true), "/auth/requests/logout/accept?logout_challenge="+url.QueryEscape(challenge), + nil, "application/json", true) + if err != nil { + logger.Error().Msg("Failed to accept logout challenge: " + err.Error()) + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, errors.New("hydra accept logout returned status " + resp.Status + ": " + string(b)) + } + var redirect Redirect + if err := json.Unmarshal(b, &redirect); err != nil { + return nil, err + } + return &redirect, nil +} + +// GetConsentChallenge retrieves consent challenge details from Hydra admin API +func (h *HydraConnector) GetConsentChallenge(challenge string) (*ConsentChallenge, error) { + logger := oclib.GetLogger() + resp, err := h.Caller.CallGet(h.getPath(true, true), "/auth/requests/consent?consent_challenge="+url.QueryEscape(challenge)) + if err != nil { + logger.Error().Msg("Failed to get consent challenge: " + err.Error()) + return nil, err + } + var result ConsentChallenge + if err := json.Unmarshal(resp, &result); err != nil { + logger.Error().Msg("Failed to unmarshal consent challenge: " + err.Error()) + return nil, err + } + return &result, nil +} + +// AcceptConsent accepts a consent challenge with claims injected into the Hydra session +func (h *HydraConnector) AcceptConsent(challenge string, grantScope []string, session claims.Claims) (*Redirect, error) { + logger := oclib.GetLogger() + body := map[string]interface{}{ + "grant_scope": grantScope, + "grant_access_token_audience": grantScope, // grant requested audience + "remember": true, + "remember_for": 3600, + "session": map[string]interface{}{ + "access_token": session.Session.AccessToken, + "id_token": session.Session.IDToken, + }, + } + resp, err := h.Caller.CallRaw(http.MethodPut, + h.getPath(true, true), "/auth/requests/consent/accept?consent_challenge="+url.QueryEscape(challenge), + body, "application/json", true) + if err != nil { + logger.Error().Msg("Failed to accept consent challenge: " + err.Error()) + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, errors.New("hydra accept consent returned status " + resp.Status + ": " + string(b)) + } + var redirect Redirect + if err := json.Unmarshal(b, &redirect); err != nil { + return nil, err + } + return &redirect, nil +} + +// Introspect verifies a token with Hydra — respects the actual response (no override) +func (h *HydraConnector) Introspect(token string) (*IntrospectResult, error) { + logger := oclib.GetLogger() + urls := url.Values{} + urls.Add("token", token) + resp, err := h.Caller.CallForm(http.MethodPost, h.getPath(true, true), "/introspect", urls, + "application/x-www-form-urlencoded", true) + if err != nil { + logger.Error().Msg("Failed to introspect token: " + err.Error()) + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, errors.New("hydra introspect returned status " + resp.Status) + } + var result IntrospectResult + if err := json.Unmarshal(b, &result); err != nil { + return nil, err + } + return &result, nil +} + +// RevokeToken revokes an OAuth2 token +func (h *HydraConnector) RevokeToken(token string, clientID string) error { + logger := oclib.GetLogger() + urls := url.Values{} + urls.Add("token", token) + urls.Add("client_id", clientID) + urls.Add("client_secret", conf.GetConfig().ClientSecret) + resp, err := h.Caller.CallForm(http.MethodPost, h.getPath(false, true), "/revoke", urls, + "application/x-www-form-urlencoded", true) + if err != nil { + logger.Error().Msg("Failed to revoke token: " + err.Error()) + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return errors.New("hydra revoke returned status " + resp.Status + ": " + string(b)) + } + return nil +} + +// RefreshToken exchanges a refresh_token for a new token set +func (h *HydraConnector) RefreshToken(refreshToken string, clientID string) (*TokenResponse, error) { + logger := oclib.GetLogger() + urls := url.Values{} + urls.Add("grant_type", "refresh_token") + urls.Add("refresh_token", refreshToken) + urls.Add("client_id", clientID) + urls.Add("client_secret", conf.GetConfig().ClientSecret) + resp, err := h.Caller.CallForm(http.MethodPost, h.getPath(false, true), "/token", urls, + "application/x-www-form-urlencoded", true) + if err != nil { + logger.Error().Msg("Failed to refresh token: " + err.Error()) + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, errors.New("hydra refresh returned status " + resp.Status + ": " + string(b)) + } + var result TokenResponse + if err := json.Unmarshal(b, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CheckAuthForward validates a JWT token for forward auth (Traefik integration) +// It introspects the token via Hydra and checks permissions from the token's extra claims +func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) bool { if forward == "" || method == "" { return false } - var c claims.Claims - token := strings.Split(reqToken, ".") - if len(token) > 2 { - bytes, err := base64.StdEncoding.DecodeString(token[2]) + logger := oclib.GetLogger() + + // Introspect the token via Hydra to get claims + result, err := h.Introspect(reqToken) + if err != nil || !result.Active { if err != nil { - return false + logger.Error().Msg("Forward auth introspect failed: " + err.Error()) } - err = json.Unmarshal(bytes, &c) - if err != nil { - return false + return false + } + + // Extract claims from the introspection result's extra data + // Hydra puts consent session's access_token data in the "ext" field of introspection + var sessionClaims claims.Claims + if result.Extra != nil { + sessionClaims.Session.AccessToken = make(map[string]interface{}) + sessionClaims.Session.IDToken = make(map[string]interface{}) + for k, v := range result.Extra { + sessionClaims.Session.AccessToken[k] = v } } - // ask keto for permission is in claims - ok, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, c, publicKey, external) + + // Also try to get id_token claims from the token if it's a JWT + // For now, use the introspected extra claims and the peer signature verification + if sessionClaims.Session.IDToken == nil { + sessionClaims.Session.IDToken = make(map[string]interface{}) + } + + // Get self peer for signature verification + pp := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search(nil, fmt.Sprintf("%v", peer.SELF.EnumIndex()), false) + if len(pp.Data) > 0 { + p := pp.Data[0].(*peer.Peer) + // Re-sign for local verification if this is our own peer + if !external && p.PublicKey == publicKey { + sessionClaims.Session.IDToken["signature"] = "" + // For internal requests, skip signature check by using the claims decoder directly + ok, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, sessionClaims, publicKey, external) + if err != nil { + logger.Error().Msg("Failed to decode claims: " + err.Error()) + } + return ok + } + } + + ok, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, sessionClaims, publicKey, external) if err != nil { - fmt.Println("Failed to decode claims", err) + logger.Error().Msg("Failed to decode claims: " + err.Error()) } return ok } + +// extractBearerToken extracts the token from a "Bearer xxx" Authorization header value +func extractBearerToken(authHeader string) string { + splitToken := strings.Split(authHeader, "Bearer ") + if len(splitToken) < 2 { + return "" + } + return splitToken[1] +} diff --git a/infrastructure/auth_connector/ldap.go b/infrastructure/auth_connector/ldap.go index df14670..2316076 100644 --- a/infrastructure/auth_connector/ldap.go +++ b/infrastructure/auth_connector/ldap.go @@ -6,13 +6,13 @@ import ( "encoding/json" "errors" "fmt" - "log" "net" "oc-auth/conf" "strings" "sync" "time" + oclib "cloud.o-forge.io/core/oc-lib" "github.com/coocood/freecache" "github.com/go-ldap/ldap/v3" "github.com/i-core/rlog" @@ -88,25 +88,23 @@ func (cli *Client) Authenticate(ctx context.Context, username string, password s } var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) - fmt.Println("Connect", ctx, username, password) + logger := oclib.GetLogger() + logger.Debug().Msgf("LDAP authenticate user: %s", username) cn, ok := <-cli.connect(ctx) cancel() if !ok { return false, errConnectionTimeout } defer cn.Close() - fmt.Println("findBasicUserDetails", cn, username, password) // Find a user DN by his or her username. details, err := cli.findBasicUserDetails(cn, username, []string{"dn"}) if err != nil || details == nil { return false, err } - fmt.Println(details) a := details["dn"] - fmt.Println(a) - log.Println("Binding DN:", a[0], "with password:", password) + logger.Debug().Msgf("Binding DN: %s", a[0]) if err := cn.Bind(a[0], password); err != nil { - fmt.Println(err) + logger.Error().Msg("LDAP bind failed: " + err.Error()) if err == errInvalidCredentials { return false, nil } @@ -283,13 +281,15 @@ func (cli *Client) connect(ctx context.Context) <-chan conn { cn, err := cli.connector.Connect(ctx, addr) if err != nil { - fmt.Println("Failed to create a LDAP connection", "address", addr, err) + log := oclib.GetLogger() + log.Error().Msgf("Failed to create LDAP connection to %s: %v", addr, err) return } select { case <-ctx.Done(): cn.Close() - fmt.Println("a LDAP connection is cancelled", "address", addr) + log := oclib.GetLogger() + log.Debug().Msgf("LDAP connection cancelled: %s", addr) return case ch <- cn: } @@ -303,7 +303,8 @@ func (cli *Client) connect(ctx context.Context) <-chan conn { } func (cli *Client) findRoles(cn conn, attrs ...string) (map[string]LDAPRoles, error) { - fmt.Println("cli", cli.BindDN, cli.BindPass) + logger := oclib.GetLogger() + logger.Debug().Msg("Finding LDAP roles") if cli.BindDN != "" { // We need to login to a LDAP server with a service account for retrieving user data. if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil { @@ -311,7 +312,7 @@ func (cli *Client) findRoles(cn conn, attrs ...string) (map[string]LDAPRoles, er } } entries, err := cn.SearchRoles(attrs...) - fmt.Println("entries", entries) + logger.Debug().Msgf("Found %d LDAP role entries", len(entries)) if err != nil { return map[string]LDAPRoles{}, err } @@ -344,7 +345,7 @@ func (cli *Client) findRoles(cn conn, attrs ...string) (map[string]LDAPRoles, er if claims[appID].Members[role] == nil { claims[appID].Members[role] = []string{} } - fmt.Println("entry", entry) + logger.Debug().Msgf("Processing role entry: %v", entry["dn"]) memberDNs, ok := entry["member"] for _, memberDN := range memberDNs { if !ok || memberDN == "" { @@ -376,7 +377,8 @@ func (cli *Client) findRoles(cn conn, attrs ...string) (map[string]LDAPRoles, er // findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user. func (cli *Client) findBasicUserDetails(cn conn, username string, attrs []string) (map[string][]string, error) { - fmt.Println("Second woth : ", cli.BindDN, cli.BindPass) + logger := oclib.GetLogger() + logger.Debug().Msgf("Finding LDAP user details for: %s", username) if cli.BindDN != "" { // We need to login to a LDAP server with a service account for retrieving user data. if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil { @@ -389,7 +391,7 @@ func (cli *Client) findBasicUserDetails(cn conn, username string, attrs []string } if len(entries) == 0 { // We didn't find the user. - fmt.Println("user not found") + logger.Debug().Msgf("LDAP user not found: %s", username) return nil, nil } @@ -470,13 +472,14 @@ func (c *ldapConn) SearchRoles(attrs ...string) ([]map[string][]string, error) { // searchEntries executes a LDAP query, and returns a result as entries where each entry is mapping of LDAP attributes. func (c *ldapConn) searchEntries(baseDN, query string, attrs []string) ([]map[string][]string, error) { - fmt.Println(baseDN, query, attrs) + log := oclib.GetLogger() + log.Debug().Msgf("LDAP search: baseDN=%s query=%s", baseDN, query) req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil) res, err := c.Search(req) if err != nil { return nil, err } - fmt.Println(res.Entries) + log.Debug().Msgf("LDAP search returned %d entries", len(res.Entries)) var entries []map[string][]string for _, v := range res.Entries { diff --git a/infrastructure/claims/claims.go b/infrastructure/claims/claims.go index 8b6fb3c..b6002e5 100644 --- a/infrastructure/claims/claims.go +++ b/infrastructure/claims/claims.go @@ -7,19 +7,23 @@ import ( "cloud.o-forge.io/core/oc-lib/models/peer" ) -// Tokenizer interface +// ClaimService builds and verifies OAuth2 session claims type ClaimService interface { - AddClaimsToToken(clientID string, userId string, peer *peer.Peer) Claims + // BuildConsentSession builds the session payload for Hydra consent accept. + // Claims are injected into the Hydra JWT via the consent session, not appended to the token. + 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) } -// SessionClaims struct +// SessionClaims contains access_token and id_token claim maps type SessionClaims struct { AccessToken map[string]interface{} `json:"access_token"` IDToken map[string]interface{} `json:"id_token"` } -// Claims struct +// Claims is the top-level session structure passed to Hydra consent accept type Claims struct { Session SessionClaims `json:"session"` } diff --git a/infrastructure/claims/hydra_claims.go b/infrastructure/claims/hydra_claims.go index 61a4b7b..5cc00ff 100644 --- a/infrastructure/claims/hydra_claims.go +++ b/infrastructure/claims/hydra_claims.go @@ -4,14 +4,13 @@ import ( "crypto/sha256" "encoding/pem" "errors" - "fmt" "oc-auth/conf" "oc-auth/infrastructure/perms_connectors" "oc-auth/infrastructure/utils" "os" "strings" - "time" + oclib "cloud.o-forge.io/core/oc-lib" "cloud.o-forge.io/core/oc-lib/models/peer" "cloud.o-forge.io/core/oc-lib/tools" ) @@ -27,7 +26,7 @@ func (h HydraClaims) generateKey(relation string, path string) (string, error) { return strings.ToUpper(method.String()) + "_" + strings.ReplaceAll(p, ":", ""), nil } -// decode key expect to extract method and path from key +// decodeKey extracts method and path from a permission key func (h HydraClaims) decodeKey(key string, external bool) (tools.METHOD, string, error) { s := strings.Split(key, "_") if len(s) < 2 { @@ -46,7 +45,10 @@ func (h HydraClaims) decodeKey(key string, external bool) (tools.METHOD, string, func (h HydraClaims) DecodeSignature(host string, signature string, publicKey string) (bool, error) { hashed := sha256.Sum256([]byte(host)) - spkiBlock, _ := pem.Decode([]byte(publicKey)) // get public key into a variable + spkiBlock, _ := pem.Decode([]byte(publicKey)) + if spkiBlock == nil { + return false, errors.New("failed to decode public key PEM") + } err := VerifyDefault(hashed[:], spkiBlock.Bytes, signature) if err != nil { return false, err @@ -56,18 +58,19 @@ func (h HydraClaims) DecodeSignature(host string, signature string, publicKey st func (h HydraClaims) encodeSignature(host string) (string, error) { hashed := sha256.Sum256([]byte(host)) - // READ FILE TO GET PRIVATE KEY FROM PVK PEM PATH content, err := os.ReadFile(conf.GetConfig().PrivateKeyPath) if err != nil { return "", err } privateKey := string(content) spkiBlock, _ := pem.Decode([]byte(privateKey)) + if spkiBlock == nil { + return "", errors.New("failed to decode private key PEM") + } return SignDefault(hashed[:], spkiBlock.Bytes) } func (h HydraClaims) clearBlank(path []string) []string { - // clear blank newPath := []string{} for _, p := range path { if p != "" { @@ -77,29 +80,33 @@ func (h HydraClaims) clearBlank(path []string) []string { return newPath } -func (a HydraClaims) CheckExpiry(exp int64) bool { - now := time.Now().UTC().Unix() - return now <= exp -} - +// 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) { + logger := oclib.GetLogger() idTokenClaims := sessionClaims.Session.IDToken - if idTokenClaims["signature"] == nil { - return false, errors.New("no signature found") - } - signature := idTokenClaims["signature"].(string) - if ok, err := h.DecodeSignature(host, signature, publicKey); !ok { - return false, err + + // 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 + } } + claims := sessionClaims.Session.AccessToken + if claims == nil { + return false, errors.New("no access_token claims found") + } + path := strings.ReplaceAll(forward, "http://"+host, "") splittedPath := h.clearBlank(strings.Split(path, "/")) - if _, ok := claims["exp"].(float64); !ok || !h.CheckExpiry(int64(claims["exp"].(float64))) { - return false, errors.New("token is expired") - } + for m, p := range claims { + pStr, ok := p.(string) + if !ok { + continue + } match := true - splittedP := h.clearBlank(strings.Split(p.(string), "/")) + splittedP := h.clearBlank(strings.Split(pStr, "/")) if len(splittedP) != len(splittedPath) { continue } @@ -118,45 +125,53 @@ func (h HydraClaims) DecodeClaimsInToken(host string, method string, forward str } perm := perms_connectors.Permission{ Relation: "permits" + strings.ToUpper(meth.String()), - Object: p.(string), + Object: pStr, } return perms_connectors.GetPermissionConnector("").CheckPermission(perm, nil, true), nil } } + logger.Error().Msg("No permission found for " + method + " " + forward) return false, errors.New("no permission found") } -// add claims to token method of HydraTokenizer -func (h HydraClaims) AddClaimsToToken(clientID string, userId string, p *peer.Peer) Claims { - claims := Claims{} +// BuildConsentSession builds the session payload for Hydra consent accept. +// Claims are injected into the Hydra JWT — not appended to the token as before. +func (h HydraClaims) BuildConsentSession(clientID string, userId string, p *peer.Peer) Claims { + logger := oclib.GetLogger() + c := Claims{} perms, err := perms_connectors.KetoConnector{}.GetPermissionByUser(userId, true) - if err != nil { - return claims + logger.Error().Msg("Failed to get permissions for user " + userId + ": " + err.Error()) + return c } - claims.Session.AccessToken = make(map[string]interface{}) - claims.Session.IDToken = make(map[string]interface{}) - fmt.Println("PERMS err 1", perms, err) + + c.Session.AccessToken = make(map[string]interface{}) + c.Session.IDToken = make(map[string]interface{}) + for _, perm := range perms { key, err := h.generateKey(strings.ReplaceAll(perm.Relation, "permits", ""), perm.Subject) if err != nil { continue } - claims.Session.AccessToken[key] = perm.Subject + c.Session.AccessToken[key] = perm.Subject } + sign, err := h.encodeSignature(p.APIUrl) if err != nil { - return claims + logger.Error().Msg("Failed to encode signature: " + err.Error()) + return c } - claims.Session.IDToken["username"] = userId - claims.Session.IDToken["peer_id"] = p.UUID - // we should get group from user + + c.Session.IDToken["username"] = userId + c.Session.IDToken["peer_id"] = p.UUID + c.Session.IDToken["client_id"] = clientID + groups, err := perms_connectors.KetoConnector{}.GetGroupByUser(userId) if err != nil { - return claims + logger.Error().Msg("Failed to get groups for user " + userId + ": " + err.Error()) + return c } - claims.Session.IDToken["client_id"] = clientID - claims.Session.IDToken["groups"] = groups - claims.Session.IDToken["signature"] = sign - return claims + c.Session.IDToken["groups"] = groups + c.Session.IDToken["signature"] = sign + return c } diff --git a/infrastructure/perms_connectors/keto_connector.go b/infrastructure/perms_connectors/keto_connector.go index 408c023..81be057 100644 --- a/infrastructure/perms_connectors/keto_connector.go +++ b/infrastructure/perms_connectors/keto_connector.go @@ -197,7 +197,8 @@ func (k KetoConnector) GetPermissionByRole(roleID string) ([]Permission, error) } func (k KetoConnector) GetPermissionByUser(userID string, internal bool) ([]Permission, error) { roles, err := k.get("", "member", userID) - fmt.Println("ROLES", roles, err) + log := oclib.GetLogger() + log.Debug().Msgf("GetPermissionByUser roles for %s: %d roles, err=%v", userID, len(roles), err) if err != nil { return nil, err } @@ -256,7 +257,8 @@ func (k KetoConnector) binds(object string, relation string, subject string) (st } func (k KetoConnector) BindRole(userID string, roleID string) (string, int, error) { - fmt.Println("BIND ROLE", userID, roleID) + log := oclib.GetLogger() + log.Debug().Msgf("BindRole: user=%s role=%s", userID, roleID) return k.binds(userID, "member", roleID) } @@ -361,12 +363,11 @@ func (k KetoConnector) createRelationShip(object string, relation string, subjec log.Error().Msg("createRelationShip" + err.Error()) return nil, 500, err } - var data map[string]interface{} + data := map[string]interface{}{} err = json.Unmarshal(b, &data) if err != nil { - fmt.Println(string(b), err) log := oclib.GetLogger() - log.Error().Msg("createRelationShip2" + err.Error()) + log.Error().Msgf("createRelationShip unmarshal error: %s, err=%v", string(b), err) return nil, 500, err } perm := &Permission{ diff --git a/main.go b/main.go index e444d16..cb4263a 100644 --- a/main.go +++ b/main.go @@ -3,24 +3,15 @@ package main import ( "context" "encoding/json" - "errors" - "fmt" "oc-auth/conf" "oc-auth/infrastructure" auth_connectors "oc-auth/infrastructure/auth_connector" _ "oc-auth/routers" - "os" - "runtime/debug" - "strconv" - "strings" "time" oclib "cloud.o-forge.io/core/oc-lib" - peer "cloud.o-forge.io/core/oc-lib/models/peer" - "cloud.o-forge.io/core/oc-lib/models/utils" "cloud.o-forge.io/core/oc-lib/tools" beego "github.com/beego/beego/v2/server/web" - "github.com/i-core/rlog" ) const appname = "oc-auth" @@ -59,7 +50,6 @@ func main() { conf.GetConfig().LDAPBaseDN = o.GetStringDefault("LDAP_BASEDN", "dc=example,dc=com") conf.GetConfig().LDAPUserBaseDN = o.GetStringDefault("LDAP_USER_BASEDN", "ou=users,dc=example,dc=com") conf.GetConfig().LDAPRoleBaseDN = o.GetStringDefault("LDAP_ROLE_BASEDN", "ou=AppRoles,dc=example,dc=com") - go generateSelfPeer() go generateRole() go discovery() @@ -67,18 +57,18 @@ func main() { } func generateRole() { + logger := oclib.GetLogger() defer func() { if r := recover(); r != nil { - fmt.Println("generateRole Recovered in f", r, debug.Stack()) + logger.Error().Msgf("generateRole recovered from panic: %v", r) } }() - // if from ldap, create roles from ldap if conf.GetConfig().SourceMode == "ldap" { for { ldap := auth_connectors.New() roles, err := ldap.GetRoles(context.Background()) if err == nil { - fmt.Println("ROLE", roles) + logger.Info().Msgf("Syncing %d LDAP role groups to Keto", len(roles)) for _, role := range roles { for r, m := range role.Members { infrastructure.GetPermissionConnector("").CreateRole(r) @@ -89,85 +79,29 @@ func generateRole() { } break } else { - time.Sleep(10 * time.Second) // Pause execution for 10 seconds + logger.Error().Msg("Failed to get LDAP roles, retrying in 10s: " + err.Error()) + time.Sleep(10 * time.Second) continue } } } } -func generateSelfPeer() error { - defer func() { - if r := recover(); r != nil { - fmt.Println("generateSelfPeer Recovered in f", r, debug.Stack()) - } - }() - log := rlog.FromContext(context.Background()).Sugar() - for { - // TODO check if files at private & public path are set - // check if files at private & public path are set - if _, err := os.Stat(conf.GetConfig().PrivateKeyPath); errors.Is(err, os.ErrNotExist) { - return errors.New("private key path does not exist") - } - if _, err := os.Stat(conf.GetConfig().PublicKeyPath); errors.Is(err, os.ErrNotExist) { - return errors.New("public key path does not exist") - } - // check if peer already exists - p := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search(nil, strconv.Itoa(peer.SELF.EnumIndex()), false) - file := "" - f, err := os.ReadFile(conf.GetConfig().PublicKeyPath) - if err != nil { - time.Sleep(10 * time.Second) - log.Error(err) - continue - } - file = string(f) - if len(p.Data) > 0 { - // check public key with the one in the database - // compare the public key from file with the one in the database - if !strings.Contains(file, p.Data[0].(*peer.Peer).PublicKey) { - return errors.New("public key is different from the one in the database") - } - return nil - } - // create a new peer - o := oclib.GetConfLoader(appname) - peer := &peer.Peer{ - APIUrl: o.GetStringDefault("HOSTNAME", "http://localhost"), - NATSAddress: oclib.GetConfig().NATSUrl, - AbstractObject: utils.AbstractObject{ - Name: o.GetStringDefault("NAME", "local"), - }, - PublicKey: file, - Relation: peer.SELF, - State: peer.ONLINE, - WalletAddress: "my-wallet", - } - data := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).StoreOne(peer.Serialize(peer)) - if data.Err != "" { - time.Sleep(10 * time.Second) // Pause execution for 10 seconds - log.Error(err) - continue - } - break - } - - return nil -} - func discovery() { + logger := oclib.GetLogger() defer func() { if r := recover(); r != nil { - fmt.Println("discovery Recovered in f", r, debug.Stack()) + logger.Error().Msgf("discovery recovered from panic: %v", r) } }() for { api := tools.API{} conn := infrastructure.GetPermissionConnector("") - fmt.Println("AdminRole", conn, conf.GetConfig().PermissionConnectorWriteHost) + logger.Info().Msg("Starting permission discovery") _, _, err := conn.CreateRole(conf.GetConfig().AdminRole) if err != nil { - time.Sleep(10 * time.Second) // Pause execution for 10 seconds + logger.Error().Msg("Failed to create admin role, retrying in 10s: " + err.Error()) + time.Sleep(10 * time.Second) continue } conn.BindRole(conf.GetConfig().AdminRole, "admin") diff --git a/oc-auth b/oc-auth index e24648f..a32f2c9 100755 Binary files a/oc-auth and b/oc-auth differ diff --git a/routers/commentsRouter.go b/routers/commentsRouter.go index 47c3c11..81a5c1a 100644 --- a/routers/commentsRouter.go +++ b/routers/commentsRouter.go @@ -79,6 +79,15 @@ func init() { Filters: nil, Params: nil}) + beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"], + beego.ControllerComments{ + Method: "Consent", + Router: `/consent`, + AllowHTTPMethods: []string{"get"}, + MethodParams: param.Make(), + Filters: nil, + Params: nil}) + beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"], beego.ControllerComments{ Method: "InternalAuthForward", @@ -97,6 +106,15 @@ func init() { Filters: nil, Params: nil}) + beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"], + beego.ControllerComments{ + Method: "GetLogin", + Router: `/login`, + AllowHTTPMethods: []string{"get"}, + MethodParams: param.Make(), + Filters: nil, + Params: nil}) + beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"], beego.ControllerComments{ Method: "Login", @@ -106,6 +124,15 @@ func init() { Filters: nil, Params: nil}) + beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"], + beego.ControllerComments{ + Method: "GetLogout", + Router: `/logout`, + AllowHTTPMethods: []string{"get"}, + MethodParams: param.Make(), + Filters: nil, + Params: nil}) + beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"], beego.ControllerComments{ Method: "LogOut", diff --git a/swagger/swagger.json b/swagger/swagger.json index 9b17f52..b22c3bd 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -15,18 +15,50 @@ }, "basePath": "/oc/", "paths": { + "/consent": { + "get": { + "tags": [ + "oc-auth/controllersOAuthController" + ], + "description": "Hydra redirects here with a consent_challenge. Auto-accepts consent with user permissions.\n\u003cbr\u003e", + "operationId": "OAuthController.Consent", + "parameters": [ + { + "in": "query", + "name": "consent_challenge", + "description": "The consent challenge from Hydra", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.Redirect" + } + }, + "400": { + "description": "missing consent_challenge" + }, + "500": { + "description": "internal error" + } + } + } + }, "/forward": { "get": { "tags": [ "oc-auth/controllersOAuthController" ], - "description": "auth forward\n\u003cbr\u003e", + "description": "Forward auth for Traefik — validates JWT via Hydra introspection\n\u003cbr\u003e", "operationId": "OAuthController.AuthForward", "parameters": [ { "in": "header", "name": "Authorization", - "description": "auth token", + "description": "Bearer token", "type": "string" } ], @@ -216,80 +248,148 @@ "tags": [ "oc-auth/controllersOAuthController" ], - "description": "introspect token\n\u003cbr\u003e", - "operationId": "OAuthController.Introspection", + "description": "Introspect a token — respects Hydra's response\n\u003cbr\u003e", + "operationId": "OAuthController.Introspect", "parameters": [ { "in": "header", "name": "Authorization", - "description": "auth token", + "description": "Bearer token", "type": "string" } ], "responses": { "200": { - "description": "{string}" + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.IntrospectResult" + } } } } }, "/login": { - "post": { + "get": { "tags": [ "oc-auth/controllersOAuthController" ], - "description": "authenticate user\n\u003cbr\u003e", - "operationId": "OAuthController.Login", + "description": "Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists.\n\u003cbr\u003e", + "operationId": "OAuthController.GetLogin", "parameters": [ - { - "in": "body", - "name": "body", - "description": "The workflow content", - "required": true, - "schema": { - "$ref": "#/definitions/models.workflow" - } - }, { "in": "query", - "name": "client_id", - "description": "the client_id you want to get", + "name": "login_challenge", + "description": "The login challenge from Hydra", "required": true, "type": "string" } ], "responses": { "200": { - "description": "{string}" + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.LoginChallenge" + } + }, + "400": { + "description": "missing login_challenge" + }, + "500": { + "description": "internal error" + } + } + }, + "post": { + "tags": [ + "oc-auth/controllersOAuthController" + ], + "description": "Authenticate user via LDAP and accept Hydra login challenge\n\u003cbr\u003e", + "operationId": "OAuthController.PostLogin", + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Login credentials and challenge", + "required": true, + "schema": { + "$ref": "#/definitions/auth_connectors.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.Redirect" + } + }, + "401": { + "description": "invalid credentials" + }, + "500": { + "description": "internal error" } } } }, "/logout": { - "delete": { + "get": { "tags": [ "oc-auth/controllersOAuthController" ], - "description": "unauthenticate user\n\u003cbr\u003e", - "operationId": "OAuthController.Logout", + "description": "Hydra redirects here with a logout_challenge. Accepts the challenge and returns a redirect URL.\n\u003cbr\u003e", + "operationId": "OAuthController.GetLogout", "parameters": [ - { - "in": "header", - "name": "Authorization", - "description": "auth token", - "type": "string" - }, { "in": "query", - "name": "client_id", - "description": "the client_id you want to get", + "name": "logout_challenge", + "description": "The logout challenge from Hydra", "required": true, "type": "string" } ], "responses": { "200": { - "description": "{string}" + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.Redirect" + } + }, + "400": { + "description": "missing logout_challenge" + }, + "500": { + "description": "internal error" + } + } + }, + "delete": { + "tags": [ + "oc-auth/controllersOAuthController" + ], + "description": "Revoke an OAuth2 token\n\u003cbr\u003e", + "operationId": "OAuthController.Logout", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "description": "Bearer token", + "type": "string" + }, + { + "in": "query", + "name": "client_id", + "description": "The client_id", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.Token" + } } } } @@ -468,29 +568,28 @@ "tags": [ "oc-auth/controllersOAuthController" ], - "description": "introspect token\n\u003cbr\u003e", - "operationId": "OAuthController.Introspection", + "description": "Exchange a refresh_token for a new token set\n\u003cbr\u003e", + "operationId": "OAuthController.Refresh", "parameters": [ { "in": "body", "name": "body", - "description": "The token info", + "description": "refresh_token and client_id", "required": true, "schema": { - "$ref": "#/definitions/models.Token" + "$ref": "#/definitions/object" } - }, - { - "in": "query", - "name": "client_id", - "description": "the client_id you want to get", - "required": true, - "type": "string" } ], "responses": { "200": { - "description": "{string}" + "description": "", + "schema": { + "$ref": "#/definitions/auth_connectors.TokenResponse" + } + }, + "401": { + "description": "invalid refresh token" } } } @@ -699,19 +798,152 @@ } }, "definitions": { - "models.Token": { - "title": "Token", + "2111.0xc0004ce750.false": { + "title": "false", "type": "object" }, - "models.workflow": { - "title": "workflow", + "3850.0xc0004ce930.false": { + "title": "false", + "type": "object" + }, + "auth_connectors.IntrospectResult": { + "title": "IntrospectResult", + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "client_id": { + "type": "string" + }, + "exp": { + "type": "integer", + "format": "int64" + }, + "ext": { + "$ref": "#/definitions/3850.0xc0004ce930.false" + }, + "scope": { + "type": "string" + }, + "sub": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } + }, + "auth_connectors.LoginChallenge": { + "title": "LoginChallenge", + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "client": { + "$ref": "#/definitions/2111.0xc0004ce750.false" + }, + "request_url": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "skip": { + "type": "boolean" + }, + "subject": { + "type": "string" + } + } + }, + "auth_connectors.LoginRequest": { + "title": "LoginRequest", + "type": "object", + "properties": { + "login_challenge": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "auth_connectors.Redirect": { + "title": "Redirect", + "type": "object", + "properties": { + "redirect_to": { + "type": "string" + } + } + }, + "auth_connectors.Token": { + "title": "Token", + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "expires_in": { + "type": "integer", + "format": "int64" + }, + "id_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } + }, + "auth_connectors.TokenResponse": { + "title": "TokenResponse", + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "format": "int64" + }, + "id_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } + }, + "object": { + "title": "object", "type": "object" } }, "tags": [ { "name": "oc-auth/controllersOAuthController", - "description": "Operations about auth\n" + "description": "OAuthController handles OAuth2 login/consent provider endpoints\n" }, { "name": "group", diff --git a/swagger/swagger.yml b/swagger/swagger.yml index bc87b31..b307612 100644 --- a/swagger/swagger.yml +++ b/swagger/swagger.yml @@ -12,18 +12,41 @@ info: url: https://www.gnu.org/licenses/agpl-3.0.html basePath: /oc/ paths: + /consent: + get: + tags: + - oc-auth/controllersOAuthController + description: |- + Hydra redirects here with a consent_challenge. Auto-accepts consent with user permissions. +
+ operationId: OAuthController.Consent + parameters: + - in: query + name: consent_challenge + description: The consent challenge from Hydra + required: true + type: string + responses: + "200": + description: "" + schema: + $ref: '#/definitions/auth_connectors.Redirect' + "400": + description: missing consent_challenge + "500": + description: internal error /forward: get: tags: - oc-auth/controllersOAuthController description: |- - auth forward + Forward auth for Traefik — validates JWT via Hydra introspection
operationId: OAuthController.AuthForward parameters: - in: header name: Authorization - description: auth token + description: Bearer token type: string responses: "200": @@ -164,61 +187,110 @@ paths: tags: - oc-auth/controllersOAuthController description: |- - introspect token + Introspect a token — respects Hydra's response
- operationId: OAuthController.Introspection + operationId: OAuthController.Introspect parameters: - in: header name: Authorization - description: auth token + description: Bearer token type: string responses: "200": - description: '{string}' + description: "" + schema: + $ref: '#/definitions/auth_connectors.IntrospectResult' /login: + get: + tags: + - oc-auth/controllersOAuthController + description: |- + Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists. +
+ operationId: OAuthController.GetLogin + parameters: + - in: query + name: login_challenge + description: The login challenge from Hydra + required: true + type: string + responses: + "200": + description: "" + schema: + $ref: '#/definitions/auth_connectors.LoginChallenge' + "400": + description: missing login_challenge + "500": + description: internal error post: tags: - oc-auth/controllersOAuthController description: |- - authenticate user + Authenticate user via LDAP and accept Hydra login challenge
- operationId: OAuthController.Login + operationId: OAuthController.PostLogin parameters: - in: body name: body - description: The workflow content + description: Login credentials and challenge required: true schema: - $ref: '#/definitions/models.workflow' + $ref: '#/definitions/auth_connectors.LoginRequest' + responses: + "200": + description: "" + schema: + $ref: '#/definitions/auth_connectors.Redirect' + "401": + description: invalid credentials + "500": + description: internal error + /logout: + get: + tags: + - oc-auth/controllersOAuthController + description: |- + Hydra redirects here with a logout_challenge. Accepts the challenge and returns a redirect URL. +
+ operationId: OAuthController.GetLogout + parameters: - in: query - name: client_id - description: the client_id you want to get + name: logout_challenge + description: The logout challenge from Hydra required: true type: string responses: "200": - description: '{string}' - /logout: + description: "" + schema: + $ref: '#/definitions/auth_connectors.Redirect' + "400": + description: missing logout_challenge + "500": + description: internal error delete: tags: - oc-auth/controllersOAuthController description: |- - unauthenticate user + Revoke an OAuth2 token
operationId: OAuthController.Logout parameters: - in: header name: Authorization - description: auth token + description: Bearer token type: string - in: query name: client_id - description: the client_id you want to get + description: The client_id required: true type: string responses: "200": - description: '{string}' + description: "" + schema: + $ref: '#/definitions/auth_connectors.Token' /permission/: get: tags: @@ -350,24 +422,23 @@ paths: tags: - oc-auth/controllersOAuthController description: |- - introspect token + Exchange a refresh_token for a new token set
- operationId: OAuthController.Introspection + operationId: OAuthController.Refresh parameters: - in: body name: body - description: The token info + description: refresh_token and client_id required: true schema: - $ref: '#/definitions/models.Token' - - in: query - name: client_id - description: the client_id you want to get - required: true - type: string + $ref: '#/definitions/object' responses: "200": - description: '{string}' + description: "" + schema: + $ref: '#/definitions/auth_connectors.TokenResponse' + "401": + description: invalid refresh token /role/: get: tags: @@ -522,16 +593,106 @@ paths: "200": description: "" definitions: - models.Token: + 2111.0xc0004ce750.false: + title: "false" + type: object + 3850.0xc0004ce930.false: + title: "false" + type: object + auth_connectors.IntrospectResult: + title: IntrospectResult + type: object + properties: + active: + type: boolean + client_id: + type: string + exp: + type: integer + format: int64 + ext: + $ref: '#/definitions/3850.0xc0004ce930.false' + scope: + type: string + sub: + type: string + token_type: + type: string + auth_connectors.LoginChallenge: + title: LoginChallenge + type: object + properties: + challenge: + type: string + client: + $ref: '#/definitions/2111.0xc0004ce750.false' + request_url: + type: string + session_id: + type: string + skip: + type: boolean + subject: + type: string + auth_connectors.LoginRequest: + title: LoginRequest + type: object + properties: + login_challenge: + type: string + password: + type: string + username: + type: string + auth_connectors.Redirect: + title: Redirect + type: object + properties: + redirect_to: + type: string + auth_connectors.Token: title: Token type: object - models.workflow: - title: workflow + properties: + access_token: + type: string + active: + type: boolean + expires_in: + type: integer + format: int64 + id_token: + type: string + refresh_token: + type: string + scope: + type: string + token_type: + type: string + auth_connectors.TokenResponse: + title: TokenResponse + type: object + properties: + access_token: + type: string + expires_in: + type: integer + format: int64 + id_token: + type: string + refresh_token: + type: string + scope: + type: string + token_type: + type: string + object: + title: object type: object tags: - name: oc-auth/controllersOAuthController description: | - Operations about auth + OAuthController handles OAuth2 login/consent provider endpoints - name: group description: | Operations about auth