3 Commits

Author SHA1 Message Date
mr
078aae8172 oc-auth OAUTH2 2026-02-19 14:56:15 +01:00
mr
048707bfe5 Bypass mode 2026-02-17 10:16:18 +01:00
mr
5f7289bb05 a.getPath(true, true) 2026-02-10 10:53:13 +01:00
15 changed files with 1381 additions and 605 deletions

View File

@@ -61,4 +61,22 @@ If default Swagger page is displayed instead of tyour api, change url in swagger
8. API call with Bearer token 8. API call with Bearer token
Browser
Hydra /oauth2/auth
Redirect /login?login_challenge=abc123
Frontend Login Page
POST username/password/login_challenge
TON backend
Hydra Admin API (accept login)
Hydra retourne redirect_to
Frontend redirige

View File

@@ -8,8 +8,8 @@ import (
"oc-auth/conf" "oc-auth/conf"
"oc-auth/infrastructure" "oc-auth/infrastructure"
auth_connectors "oc-auth/infrastructure/auth_connector" auth_connectors "oc-auth/infrastructure/auth_connector"
"oc-auth/infrastructure/claims"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -19,153 +19,357 @@ import (
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
) )
// Operations about auth // OAuthController handles OAuth2 login/consent provider endpoints
type OAuthController struct { type OAuthController struct {
beego.Controller beego.Controller
} }
// @Title Logout // @Title GetLogin
// @Description unauthenticate user // @Description Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists.
// @Param Authorization header string false "auth token" // @Param login_challenge query string true "The login challenge from Hydra"
// @Param client_id query string true "the client_id you want to get" // @Success 200 {object} auth_connectors.LoginChallenge
// @Success 200 {string} // @Failure 400 missing login_challenge
// @router /logout [delete] // @Failure 500 internal error
func (o *OAuthController) LogOut() { // @router /login [get]
// authorize user func (o *OAuthController) GetLogin() {
clientID := o.Ctx.Input.Query("client_id") logger := oclib.GetLogger()
reqToken := o.Ctx.Request.Header.Get("Authorization") challenge := o.Ctx.Input.Query("login_challenge")
splitToken := strings.Split(reqToken, "Bearer ") if challenge == "" {
if len(splitToken) < 2 { o.Ctx.ResponseWriter.WriteHeader(400)
reqToken = "" o.Data["json"] = map[string]string{"error": "missing login_challenge parameter"}
} else { o.ServeJSON()
reqToken = splitToken[1] return
} }
var res auth_connectors.Token
json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &res)
if !conf.GetConfig().Local { if conf.GetConfig().Local {
token, err := infrastructure.GetAuthConnector().Logout(clientID, reqToken) // In local mode, return a mock challenge for dev
if err != nil || token == nil { o.Data["json"] = &auth_connectors.LoginChallenge{
o.Data["json"] = err Skip: false,
} else { Challenge: challenge,
o.Data["json"] = token
} }
} else { o.ServeJSON()
o.Data["json"] = reqToken return
} }
o.ServeJSON()
}
// @Title Login loginChallenge, err := infrastructure.GetAuthConnector().GetLoginChallenge(challenge)
// @Description authenticate user if err != nil {
// @Param body body models.workflow true "The workflow content" logger.Error().Msg("Failed to get login challenge: " + err.Error())
// @Param client_id query string true "the client_id you want to get" o.Ctx.ResponseWriter.WriteHeader(500)
// @Success 200 {string} o.Data["json"] = map[string]string{"error": err.Error()}
// @router /login [post] o.ServeJSON()
func (o *OAuthController) Login() { return
// authorize user }
clientID := o.Ctx.Input.Query("client_id")
var res auth_connectors.Token
json.Unmarshal(o.Ctx.Input.CopyBody(10000000), &res)
if conf.GetConfig().SourceMode == "ldap" { // If skip is true, the user already has an active session — auto-accept
ldap := auth_connectors.New() if loginChallenge.Skip {
found, err := ldap.Authenticate(o.Ctx.Request.Context(), res.Username, res.Password) redirect, err := infrastructure.GetAuthConnector().AcceptLogin(challenge, loginChallenge.Subject)
fmt.Println("login", clientID, found, err) if err != nil {
if err != nil || !found { logger.Error().Msg("Failed to auto-accept login: " + err.Error())
o.Data["json"] = err o.Ctx.ResponseWriter.WriteHeader(500)
o.Ctx.ResponseWriter.WriteHeader(401) o.Data["json"] = map[string]string{"error": err.Error()}
o.ServeJSON() o.ServeJSON()
return return
} }
o.Data["json"] = redirect
o.ServeJSON()
return
} }
if !conf.GetConfig().Local {
token, err := infrastructure.GetAuthConnector().Login( // Return challenge info so frontend can render login form
clientID, res.Username, o.Data["json"] = loginChallenge
&http.Cookie{ // open a session o.ServeJSON()
Name: "csrf_token", }
Value: o.XSRFToken(),
}) // @Title PostLogin
fmt.Println("login token", token, err) // @Description Authenticate user via LDAP and accept Hydra login challenge
if err != nil || token == nil { // @Param body body auth_connectors.LoginRequest true "Login credentials and challenge"
o.Data["json"] = err // @Success 200 {object} auth_connectors.Redirect
o.Ctx.ResponseWriter.WriteHeader(401) // @Failure 401 invalid credentials
} else { // @Failure 500 internal error
o.Data["json"] = token // @router /login [post]
} func (o *OAuthController) Login() {
} else { 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( t := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search(
nil, fmt.Sprintf("%v", model.SELF.EnumIndex()), false) nil, fmt.Sprintf("%v", model.SELF.EnumIndex()), false)
if t.Err == "" && len(t.Data) > 0 { if t.Err == "" && len(t.Data) > 0 {
token := &auth_connectors.Token{ p := t.Data[0].(*model.Peer)
Username: res.Username, c := infrastructure.GetClaims().BuildConsentSession("local", req.Username, p)
Password: res.Password,
TokenType: "Bearer",
Active: true,
ExpiresIn: 3600,
AccessToken: "localtoken",
}
now := time.Now().UTC() now := time.Now().UTC()
now = now.Add(time.Duration(token.ExpiresIn) * time.Second) now = now.Add(3600 * time.Second)
unix := now.Unix() c.Session.AccessToken["exp"] = now.Unix()
c := claims.GetClaims().AddClaimsToToken(clientID, res.Username, t.Data[0].(*model.Peer))
c.Session.AccessToken["exp"] = unix
b, _ := json.Marshal(c) 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 o.Data["json"] = token
} else { } else {
o.Data["json"] = t.Err
o.Ctx.ResponseWriter.WriteHeader(401) 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() o.ServeJSON()
} }
// @Title Introspection // @Title Refresh
// @Description introspect token // @Description Exchange a refresh_token for a new token set
// @Param body body models.Token true "The token info" // @Param body body object true "refresh_token and client_id"
// @Param client_id query string true "the client_id you want to get" // @Success 200 {object} auth_connectors.TokenResponse
// @Success 200 {string} // @Failure 401 invalid refresh token
// @router /refresh [post] // @router /refresh [post]
func (o *OAuthController) Refresh() { func (o *OAuthController) Refresh() {
clientID := o.Ctx.Input.Query("client_id") logger := oclib.GetLogger()
var token auth_connectors.Token var body struct {
json.Unmarshal(o.Ctx.Input.CopyBody(100000), &token) RefreshToken string `json:"refresh_token"`
// refresh token ClientID string `json:"client_id"`
if !conf.GetConfig().Local { }
newToken, err := infrastructure.GetAuthConnector().Refresh(clientID, &token) json.Unmarshal(o.Ctx.Input.CopyBody(100000), &body)
if err != nil || newToken == nil {
o.Data["json"] = err if conf.GetConfig().Local {
o.Ctx.ResponseWriter.WriteHeader(401) o.Data["json"] = map[string]string{"error": "refresh not supported in local mode"}
} else { o.Ctx.ResponseWriter.WriteHeader(400)
o.Data["json"] = newToken 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 { } else {
o.Data["json"] = token o.Data["json"] = token
} }
o.ServeJSON() o.ServeJSON()
} }
// @Title Introspection // @Title Introspect
// @Description introspect token // @Description Introspect a token — respects Hydra's response
// @Param Authorization header string false "auth token" // @Param Authorization header string false "Bearer token"
// @Success 200 {string} // @Success 200 {object} auth_connectors.IntrospectResult
// @router /introspect [get] // @router /introspect [get]
func (o *OAuthController) Introspect() { func (o *OAuthController) Introspect() {
reqToken := o.Ctx.Request.Header.Get("Authorization") reqToken := extractBearerToken(o.Ctx.Request)
splitToken := strings.Split(reqToken, "Bearer ") if reqToken == "" {
if len(splitToken) < 2 { o.Ctx.ResponseWriter.WriteHeader(401)
reqToken = "" o.Data["json"] = map[string]string{"error": "missing bearer token"}
} else { o.ServeJSON()
reqToken = splitToken[1] return
} }
if !conf.GetConfig().Local {
token, err := infrastructure.GetAuthConnector().Introspect(reqToken) if conf.GetConfig().Local {
if err != nil || !token { o.Data["json"] = &auth_connectors.IntrospectResult{Active: true}
o.Data["json"] = err o.ServeJSON()
o.Ctx.ResponseWriter.WriteHeader(401) 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() o.ServeJSON()
} }
@@ -174,15 +378,15 @@ var whitelist = []string{
"/login", "/login",
"/refresh", "/refresh",
"/introspect", "/introspect",
"/consent",
} }
// @Title AuthForward // @Title AuthForward
// @Description auth forward // @Description Forward auth for Traefik — validates JWT via Hydra introspection
// @Param Authorization header string false "auth token" // @Param Authorization header string false "Bearer token"
// @Success 200 {string} // @Success 200 {string}
// @router /forward [get] // @router /forward [get]
func (o *OAuthController) InternalAuthForward() { func (o *OAuthController) InternalAuthForward() {
fmt.Println("InternalAuthForward")
reqToken := o.Ctx.Request.Header.Get("Authorization") reqToken := o.Ctx.Request.Header.Get("Authorization")
if reqToken == "" { if reqToken == "" {
for _, w := range whitelist { for _, w := range whitelist {
@@ -203,7 +407,7 @@ func (o *OAuthController) InternalAuthForward() {
reqToken = splitToken[1] reqToken = splitToken[1]
} }
origin, publicKey, external := o.extractOrigin(o.Ctx.Request) origin, publicKey, external := o.extractOrigin(o.Ctx.Request)
if !infrastructure.GetAuthConnector().CheckAuthForward( //reqToken != "" && if !infrastructure.GetAuthConnector().CheckAuthForward(
reqToken, publicKey, origin, reqToken, publicKey, origin,
o.Ctx.Request.Header.Get("X-Forwarded-Method"), o.Ctx.Request.Header.Get("X-Forwarded-Method"),
o.Ctx.Request.Header.Get("X-Forwarded-Uri"), external) && origin != "" && publicKey != "" { o.Ctx.Request.Header.Get("X-Forwarded-Uri"), external) && origin != "" && publicKey != "" {
@@ -229,13 +433,13 @@ func (o *OAuthController) extractOrigin(request *http.Request) (string, string,
searchStr = strings.Replace(searchStr, t, "", -1) searchStr = strings.Replace(searchStr, t, "", -1)
} }
pp := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), user, peerID, groups, nil).Search(nil, searchStr, false) 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 return "", "", external
} }
p := pp.Data[0].(*model.Peer) p := pp.Data[0].(*model.Peer)
publicKey = p.PublicKey publicKey = p.PublicKey
origin = p.APIUrl origin = p.APIUrl
if origin != "" { // is external if origin != "" {
if p.Relation == peer.SELF { if p.Relation == peer.SELF {
external = false external = false
} }
@@ -245,28 +449,70 @@ func (o *OAuthController) extractOrigin(request *http.Request) (string, string,
return origin, publicKey, external 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 { func ExtractClient(request http.Request) string {
reqToken := request.Header.Get("Authorization") reqToken := request.Header.Get("Authorization")
splitToken := strings.Split(reqToken, "Bearer ") splitToken := strings.Split(reqToken, "Bearer ")
if len(splitToken) < 2 { if len(splitToken) < 2 {
reqToken = "" return ""
} else {
reqToken = splitToken[1]
} }
if reqToken != "" { reqToken = splitToken[1]
token := strings.Split(reqToken, ".") if reqToken == "" {
if len(token) > 2 { return ""
bytes, err := base64.StdEncoding.DecodeString(token[2]) }
// 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 { if err != nil {
return "" return ""
} }
m := map[string]interface{}{} }
err = json.Unmarshal(bytes, &m) m := map[string]interface{}{}
if err != nil { if err := json.Unmarshal(bytes, &m); err != nil {
return "" 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 "" 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]
}

2
go.mod
View File

@@ -3,7 +3,7 @@ module oc-auth
go 1.24.6 go 1.24.6
require ( require (
cloud.o-forge.io/core/oc-lib v0.0.0-20260210081202-3bcf0da56aa1 cloud.o-forge.io/core/oc-lib v0.0.0-20260219084344-9662ac6d678c
github.com/beego/beego/v2 v2.3.1 github.com/beego/beego/v2 v2.3.1
github.com/smartystreets/goconvey v1.7.2 github.com/smartystreets/goconvey v1.7.2
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0

4
go.sum
View File

@@ -2,6 +2,10 @@ cloud.o-forge.io/core/oc-lib v0.0.0-20260204083845-d9f646aac28b h1:/TkmuO5ERpHJC
cloud.o-forge.io/core/oc-lib v0.0.0-20260204083845-d9f646aac28b/go.mod h1:T0UCxRd8w+qCVVC0NEyDiWIGC5ADwEbQ7hFcvftd4Ks= cloud.o-forge.io/core/oc-lib v0.0.0-20260204083845-d9f646aac28b/go.mod h1:T0UCxRd8w+qCVVC0NEyDiWIGC5ADwEbQ7hFcvftd4Ks=
cloud.o-forge.io/core/oc-lib v0.0.0-20260210081202-3bcf0da56aa1 h1:CSPqJlSepu0efDRFV8tv62Fg5XP2UwSZKfaaL81YuVY= cloud.o-forge.io/core/oc-lib v0.0.0-20260210081202-3bcf0da56aa1 h1:CSPqJlSepu0efDRFV8tv62Fg5XP2UwSZKfaaL81YuVY=
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-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 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= 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= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

View File

@@ -1,8 +1,8 @@
package auth_connectors package auth_connectors
import ( import (
"net/http"
"oc-auth/conf" "oc-auth/conf"
"oc-auth/infrastructure/claims"
"strings" "strings"
"cloud.o-forge.io/core/oc-lib/tools" "cloud.o-forge.io/core/oc-lib/tools"
@@ -10,31 +10,103 @@ import (
type AuthConnector interface { type AuthConnector interface {
Status() tools.State Status() tools.State
Login(clientID string, username string, cookies ...*http.Cookie) (*Token, error)
Logout(clientID string, token string, cookies ...*http.Cookie) (*Token, error) // Login/Consent Provider endpoints (Hydra redirects here)
Introspect(token string, cookie ...*http.Cookie) (bool, error) GetLoginChallenge(challenge string) (*LoginChallenge, error)
Refresh(client_id string, token *Token) (*Token, 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 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 { type Token struct {
Active bool `json:"active"` Active bool `json:"active"`
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"` RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"` IDToken string `json:"id_token,omitempty"`
ExpiresIn int64 `json:"expires_in"`
Username string `json:"username,omitempty"` TokenType string `json:"token_type"`
Password string `json:"password,omitempty"` 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 { type Redirect struct {
RedirectTo string `json:"redirect_to"` 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{ var a = map[string]AuthConnector{
"hydra": HydraConnector{ "hydra": &HydraConnector{
Caller: tools.NewHTTPCaller(map[tools.DataType]map[tools.METHOD]string{}), Caller: tools.NewHTTPCaller(map[tools.DataType]map[tools.METHOD]string{}),
State: "12345678", ResponseType: "token", Scopes: "openid profile email roles"}, // base url },
} }
func GetAuthConnector() AuthConnector { func GetAuthConnector() AuthConnector {

View File

@@ -1,7 +1,6 @@
package auth_connectors package auth_connectors
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -10,10 +9,7 @@ import (
"net/url" "net/url"
"oc-auth/conf" "oc-auth/conf"
"oc-auth/infrastructure/claims" "oc-auth/infrastructure/claims"
"regexp"
"strconv"
"strings" "strings"
"time"
oclib "cloud.o-forge.io/core/oc-lib" oclib "cloud.o-forge.io/core/oc-lib"
"cloud.o-forge.io/core/oc-lib/models/peer" "cloud.o-forge.io/core/oc-lib/models/peer"
@@ -21,14 +17,10 @@ import (
) )
type HydraConnector struct { type HydraConnector struct {
State string `json:"state"`
Scopes string `json:"scope"`
ResponseType string `json:"response_type"`
Caller *tools.HTTPCaller 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{}) caller := tools.NewHTTPCaller(map[tools.DataType]map[tools.METHOD]string{})
var responseBody map[string]interface{} var responseBody map[string]interface{}
host := conf.GetConfig().AuthConnectPublicHost host := conf.GetConfig().AuthConnectPublicHost
@@ -47,222 +39,8 @@ func (a HydraConnector) Status() tools.State {
return tools.ALIVE return tools.ALIVE
} }
// urlFormat formats the URL of the peer with the data type API function // getPath builds the base URL for Hydra API calls
func (a *HydraConnector) urlFormat(url string, replaceWith string) string { func (h *HydraConnector) getPath(isAdmin bool, isOauth bool) 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
fmt.Println(a.getPath(true, true), "/auth/requests/"+challenge+"/accept?"+challenge+"_challenge="+s[1], resp, err)
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
}
fmt.Println(string(b))
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...)
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
return introspect.Active, nil
}
func (a HydraConnector) getPath(isAdmin bool, isOauth bool) string {
host := conf.GetConfig().AuthConnectPublicHost host := conf.GetConfig().AuthConnectPublicHost
if isAdmin { if isAdmin {
host = conf.GetConfig().AuthConnectorHost host = conf.GetConfig().AuthConnectorHost
@@ -279,29 +57,310 @@ func (a HydraConnector) getPath(isAdmin bool, isOauth bool) string {
oauth = "/oauth2" oauth = "/oauth2"
} }
return "http://" + host + ":" + port + oauth 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 == "" { if forward == "" || method == "" {
return false return false
} }
var c claims.Claims logger := oclib.GetLogger()
token := strings.Split(reqToken, ".")
if len(token) > 2 { // Introspect the token via Hydra to get claims
bytes, err := base64.StdEncoding.DecodeString(token[2]) result, err := h.Introspect(reqToken)
if err != nil || !result.Active {
if err != nil { if err != nil {
return false logger.Error().Msg("Forward auth introspect failed: " + err.Error())
} }
err = json.Unmarshal(bytes, &c) return false
if err != nil { }
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 { if err != nil {
fmt.Println("Failed to decode claims", err) logger.Error().Msg("Failed to decode claims: " + err.Error())
} }
return ok 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]
}

View File

@@ -6,13 +6,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net" "net"
"oc-auth/conf" "oc-auth/conf"
"strings" "strings"
"sync" "sync"
"time" "time"
oclib "cloud.o-forge.io/core/oc-lib"
"github.com/coocood/freecache" "github.com/coocood/freecache"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/i-core/rlog" "github.com/i-core/rlog"
@@ -88,25 +88,23 @@ func (cli *Client) Authenticate(ctx context.Context, username string, password s
} }
var cancel context.CancelFunc var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx) 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) cn, ok := <-cli.connect(ctx)
cancel() cancel()
if !ok { if !ok {
return false, errConnectionTimeout return false, errConnectionTimeout
} }
defer cn.Close() defer cn.Close()
fmt.Println("findBasicUserDetails", cn, username, password)
// Find a user DN by his or her username. // Find a user DN by his or her username.
details, err := cli.findBasicUserDetails(cn, username, []string{"dn"}) details, err := cli.findBasicUserDetails(cn, username, []string{"dn"})
if err != nil || details == nil { if err != nil || details == nil {
return false, err return false, err
} }
fmt.Println(details)
a := details["dn"] a := details["dn"]
fmt.Println(a) logger.Debug().Msgf("Binding DN: %s", a[0])
log.Println("Binding DN:", a[0], "with password:", password)
if err := cn.Bind(a[0], password); err != nil { if err := cn.Bind(a[0], password); err != nil {
fmt.Println(err) logger.Error().Msg("LDAP bind failed: " + err.Error())
if err == errInvalidCredentials { if err == errInvalidCredentials {
return false, nil return false, nil
} }
@@ -283,13 +281,15 @@ func (cli *Client) connect(ctx context.Context) <-chan conn {
cn, err := cli.connector.Connect(ctx, addr) cn, err := cli.connector.Connect(ctx, addr)
if err != nil { 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 return
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
cn.Close() cn.Close()
fmt.Println("a LDAP connection is cancelled", "address", addr) log := oclib.GetLogger()
log.Debug().Msgf("LDAP connection cancelled: %s", addr)
return return
case ch <- cn: 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) { 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 != "" { if cli.BindDN != "" {
// We need to login to a LDAP server with a service account for retrieving user data. // 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 { 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...) entries, err := cn.SearchRoles(attrs...)
fmt.Println("entries", entries) logger.Debug().Msgf("Found %d LDAP role entries", len(entries))
if err != nil { if err != nil {
return map[string]LDAPRoles{}, err 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 { if claims[appID].Members[role] == nil {
claims[appID].Members[role] = []string{} claims[appID].Members[role] = []string{}
} }
fmt.Println("entry", entry) logger.Debug().Msgf("Processing role entry: %v", entry["dn"])
memberDNs, ok := entry["member"] memberDNs, ok := entry["member"]
for _, memberDN := range memberDNs { for _, memberDN := range memberDNs {
if !ok || memberDN == "" { 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. // 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) { 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 != "" { if cli.BindDN != "" {
// We need to login to a LDAP server with a service account for retrieving user data. // 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 { 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 { if len(entries) == 0 {
// We didn't find the user. // We didn't find the user.
fmt.Println("user not found") logger.Debug().Msgf("LDAP user not found: %s", username)
return nil, nil 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. // 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) { 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) req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil)
res, err := c.Search(req) res, err := c.Search(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fmt.Println(res.Entries) log.Debug().Msgf("LDAP search returned %d entries", len(res.Entries))
var entries []map[string][]string var entries []map[string][]string
for _, v := range res.Entries { for _, v := range res.Entries {

View File

@@ -7,19 +7,23 @@ import (
"cloud.o-forge.io/core/oc-lib/models/peer" "cloud.o-forge.io/core/oc-lib/models/peer"
) )
// Tokenizer interface // ClaimService builds and verifies OAuth2 session claims
type ClaimService interface { 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) 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 { type SessionClaims struct {
AccessToken map[string]interface{} `json:"access_token"` AccessToken map[string]interface{} `json:"access_token"`
IDToken map[string]interface{} `json:"id_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 { type Claims struct {
Session SessionClaims `json:"session"` Session SessionClaims `json:"session"`
} }

View File

@@ -4,14 +4,13 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt"
"oc-auth/conf" "oc-auth/conf"
"oc-auth/infrastructure/perms_connectors" "oc-auth/infrastructure/perms_connectors"
"oc-auth/infrastructure/utils" "oc-auth/infrastructure/utils"
"os" "os"
"strings" "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/models/peer"
"cloud.o-forge.io/core/oc-lib/tools" "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 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) { func (h HydraClaims) decodeKey(key string, external bool) (tools.METHOD, string, error) {
s := strings.Split(key, "_") s := strings.Split(key, "_")
if len(s) < 2 { 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) { func (h HydraClaims) DecodeSignature(host string, signature string, publicKey string) (bool, error) {
hashed := sha256.Sum256([]byte(host)) 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) err := VerifyDefault(hashed[:], spkiBlock.Bytes, signature)
if err != nil { if err != nil {
return false, err 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) { func (h HydraClaims) encodeSignature(host string) (string, error) {
hashed := sha256.Sum256([]byte(host)) hashed := sha256.Sum256([]byte(host))
// READ FILE TO GET PRIVATE KEY FROM PVK PEM PATH
content, err := os.ReadFile(conf.GetConfig().PrivateKeyPath) content, err := os.ReadFile(conf.GetConfig().PrivateKeyPath)
if err != nil { if err != nil {
return "", err return "", err
} }
privateKey := string(content) privateKey := string(content)
spkiBlock, _ := pem.Decode([]byte(privateKey)) spkiBlock, _ := pem.Decode([]byte(privateKey))
if spkiBlock == nil {
return "", errors.New("failed to decode private key PEM")
}
return SignDefault(hashed[:], spkiBlock.Bytes) return SignDefault(hashed[:], spkiBlock.Bytes)
} }
func (h HydraClaims) clearBlank(path []string) []string { func (h HydraClaims) clearBlank(path []string) []string {
// clear blank
newPath := []string{} newPath := []string{}
for _, p := range path { for _, p := range path {
if p != "" { if p != "" {
@@ -77,29 +80,33 @@ func (h HydraClaims) clearBlank(path []string) []string {
return newPath return newPath
} }
func (a HydraClaims) CheckExpiry(exp int64) bool { // DecodeClaimsInToken verifies permissions from claims in a standard JWT (via introspection)
now := time.Now().UTC().Unix()
return now <= exp
}
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, error) {
logger := oclib.GetLogger()
idTokenClaims := sessionClaims.Session.IDToken idTokenClaims := sessionClaims.Session.IDToken
if idTokenClaims["signature"] == nil {
return false, errors.New("no signature found") // Signature verification: skip if signature is empty (internal requests)
} if sig, ok := idTokenClaims["signature"].(string); ok && sig != "" {
signature := idTokenClaims["signature"].(string) if ok, err := h.DecodeSignature(host, sig, publicKey); !ok {
if ok, err := h.DecodeSignature(host, signature, publicKey); !ok { return false, err
return false, err }
} }
claims := sessionClaims.Session.AccessToken claims := sessionClaims.Session.AccessToken
if claims == nil {
return false, errors.New("no access_token claims found")
}
path := strings.ReplaceAll(forward, "http://"+host, "") path := strings.ReplaceAll(forward, "http://"+host, "")
splittedPath := h.clearBlank(strings.Split(path, "/")) 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 { for m, p := range claims {
pStr, ok := p.(string)
if !ok {
continue
}
match := true match := true
splittedP := h.clearBlank(strings.Split(p.(string), "/")) splittedP := h.clearBlank(strings.Split(pStr, "/"))
if len(splittedP) != len(splittedPath) { if len(splittedP) != len(splittedPath) {
continue continue
} }
@@ -118,45 +125,53 @@ func (h HydraClaims) DecodeClaimsInToken(host string, method string, forward str
} }
perm := perms_connectors.Permission{ perm := perms_connectors.Permission{
Relation: "permits" + strings.ToUpper(meth.String()), Relation: "permits" + strings.ToUpper(meth.String()),
Object: p.(string), Object: pStr,
} }
return perms_connectors.GetPermissionConnector("").CheckPermission(perm, nil, true), nil 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") return false, errors.New("no permission found")
} }
// add claims to token method of HydraTokenizer // BuildConsentSession builds the session payload for Hydra consent accept.
func (h HydraClaims) AddClaimsToToken(clientID string, userId string, p *peer.Peer) Claims { // Claims are injected into the Hydra JWT — not appended to the token as before.
claims := Claims{} 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) perms, err := perms_connectors.KetoConnector{}.GetPermissionByUser(userId, true)
if err != nil { 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{}) c.Session.AccessToken = make(map[string]interface{})
fmt.Println("PERMS err 1", perms, err) c.Session.IDToken = make(map[string]interface{})
for _, perm := range perms { for _, perm := range perms {
key, err := h.generateKey(strings.ReplaceAll(perm.Relation, "permits", ""), perm.Subject) key, err := h.generateKey(strings.ReplaceAll(perm.Relation, "permits", ""), perm.Subject)
if err != nil { if err != nil {
continue continue
} }
claims.Session.AccessToken[key] = perm.Subject c.Session.AccessToken[key] = perm.Subject
} }
sign, err := h.encodeSignature(p.APIUrl) sign, err := h.encodeSignature(p.APIUrl)
if err != nil { 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 c.Session.IDToken["username"] = userId
// we should get group from user c.Session.IDToken["peer_id"] = p.UUID
c.Session.IDToken["client_id"] = clientID
groups, err := perms_connectors.KetoConnector{}.GetGroupByUser(userId) groups, err := perms_connectors.KetoConnector{}.GetGroupByUser(userId)
if err != nil { 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 c.Session.IDToken["groups"] = groups
claims.Session.IDToken["groups"] = groups c.Session.IDToken["signature"] = sign
claims.Session.IDToken["signature"] = sign return c
return claims
} }

View File

@@ -197,7 +197,8 @@ func (k KetoConnector) GetPermissionByRole(roleID string) ([]Permission, error)
} }
func (k KetoConnector) GetPermissionByUser(userID string, internal bool) ([]Permission, error) { func (k KetoConnector) GetPermissionByUser(userID string, internal bool) ([]Permission, error) {
roles, err := k.get("", "member", userID) 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 { if err != nil {
return nil, err 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) { 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) 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()) log.Error().Msg("createRelationShip" + err.Error())
return nil, 500, err return nil, 500, err
} }
var data map[string]interface{} data := map[string]interface{}{}
err = json.Unmarshal(b, &data) err = json.Unmarshal(b, &data)
if err != nil { if err != nil {
fmt.Println(string(b), err)
log := oclib.GetLogger() 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 return nil, 500, err
} }
perm := &Permission{ perm := &Permission{

86
main.go
View File

@@ -3,24 +3,15 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"oc-auth/conf" "oc-auth/conf"
"oc-auth/infrastructure" "oc-auth/infrastructure"
auth_connectors "oc-auth/infrastructure/auth_connector" auth_connectors "oc-auth/infrastructure/auth_connector"
_ "oc-auth/routers" _ "oc-auth/routers"
"os"
"runtime/debug"
"strconv"
"strings"
"time" "time"
oclib "cloud.o-forge.io/core/oc-lib" 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" "cloud.o-forge.io/core/oc-lib/tools"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
"github.com/i-core/rlog"
) )
const appname = "oc-auth" const appname = "oc-auth"
@@ -59,7 +50,6 @@ func main() {
conf.GetConfig().LDAPBaseDN = o.GetStringDefault("LDAP_BASEDN", "dc=example,dc=com") 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().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") conf.GetConfig().LDAPRoleBaseDN = o.GetStringDefault("LDAP_ROLE_BASEDN", "ou=AppRoles,dc=example,dc=com")
go generateSelfPeer()
go generateRole() go generateRole()
go discovery() go discovery()
@@ -67,18 +57,18 @@ func main() {
} }
func generateRole() { func generateRole() {
logger := oclib.GetLogger()
defer func() { defer func() {
if r := recover(); r != nil { 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" { if conf.GetConfig().SourceMode == "ldap" {
for { for {
ldap := auth_connectors.New() ldap := auth_connectors.New()
roles, err := ldap.GetRoles(context.Background()) roles, err := ldap.GetRoles(context.Background())
if err == nil { if err == nil {
fmt.Println("ROLE", roles) logger.Info().Msgf("Syncing %d LDAP role groups to Keto", len(roles))
for _, role := range roles { for _, role := range roles {
for r, m := range role.Members { for r, m := range role.Members {
infrastructure.GetPermissionConnector("").CreateRole(r) infrastructure.GetPermissionConnector("").CreateRole(r)
@@ -89,85 +79,29 @@ func generateRole() {
} }
break break
} else { } 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 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() { func discovery() {
logger := oclib.GetLogger()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
fmt.Println("discovery Recovered in f", r, debug.Stack()) logger.Error().Msgf("discovery recovered from panic: %v", r)
} }
}() }()
for { for {
api := tools.API{} api := tools.API{}
conn := infrastructure.GetPermissionConnector("") conn := infrastructure.GetPermissionConnector("")
fmt.Println("AdminRole", conn, conf.GetConfig().PermissionConnectorWriteHost) logger.Info().Msg("Starting permission discovery")
_, _, err := conn.CreateRole(conf.GetConfig().AdminRole) _, _, err := conn.CreateRole(conf.GetConfig().AdminRole)
if err != nil { 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 continue
} }
conn.BindRole(conf.GetConfig().AdminRole, "admin") conn.BindRole(conf.GetConfig().AdminRole, "admin")

BIN
oc-auth

Binary file not shown.

View File

@@ -79,6 +79,15 @@ func init() {
Filters: nil, Filters: nil,
Params: 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.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"],
beego.ControllerComments{ beego.ControllerComments{
Method: "InternalAuthForward", Method: "InternalAuthForward",
@@ -97,6 +106,15 @@ func init() {
Filters: nil, Filters: nil,
Params: 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.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"],
beego.ControllerComments{ beego.ControllerComments{
Method: "Login", Method: "Login",
@@ -106,6 +124,15 @@ func init() {
Filters: nil, Filters: nil,
Params: 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.GlobalControllerRouter["oc-auth/controllers:OAuthController"] = append(beego.GlobalControllerRouter["oc-auth/controllers:OAuthController"],
beego.ControllerComments{ beego.ControllerComments{
Method: "LogOut", Method: "LogOut",

View File

@@ -15,18 +15,50 @@
}, },
"basePath": "/oc/", "basePath": "/oc/",
"paths": { "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": { "/forward": {
"get": { "get": {
"tags": [ "tags": [
"oc-auth/controllersOAuthController" "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", "operationId": "OAuthController.AuthForward",
"parameters": [ "parameters": [
{ {
"in": "header", "in": "header",
"name": "Authorization", "name": "Authorization",
"description": "auth token", "description": "Bearer token",
"type": "string" "type": "string"
} }
], ],
@@ -216,80 +248,148 @@
"tags": [ "tags": [
"oc-auth/controllersOAuthController" "oc-auth/controllersOAuthController"
], ],
"description": "introspect token\n\u003cbr\u003e", "description": "Introspect a token — respects Hydra's response\n\u003cbr\u003e",
"operationId": "OAuthController.Introspection", "operationId": "OAuthController.Introspect",
"parameters": [ "parameters": [
{ {
"in": "header", "in": "header",
"name": "Authorization", "name": "Authorization",
"description": "auth token", "description": "Bearer token",
"type": "string" "type": "string"
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "{string}" "description": "",
"schema": {
"$ref": "#/definitions/auth_connectors.IntrospectResult"
}
} }
} }
} }
}, },
"/login": { "/login": {
"post": { "get": {
"tags": [ "tags": [
"oc-auth/controllersOAuthController" "oc-auth/controllersOAuthController"
], ],
"description": "authenticate user\n\u003cbr\u003e", "description": "Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists.\n\u003cbr\u003e",
"operationId": "OAuthController.Login", "operationId": "OAuthController.GetLogin",
"parameters": [ "parameters": [
{
"in": "body",
"name": "body",
"description": "The workflow content",
"required": true,
"schema": {
"$ref": "#/definitions/models.workflow"
}
},
{ {
"in": "query", "in": "query",
"name": "client_id", "name": "login_challenge",
"description": "the client_id you want to get", "description": "The login challenge from Hydra",
"required": true, "required": true,
"type": "string" "type": "string"
} }
], ],
"responses": { "responses": {
"200": { "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": { "/logout": {
"delete": { "get": {
"tags": [ "tags": [
"oc-auth/controllersOAuthController" "oc-auth/controllersOAuthController"
], ],
"description": "unauthenticate user\n\u003cbr\u003e", "description": "Hydra redirects here with a logout_challenge. Accepts the challenge and returns a redirect URL.\n\u003cbr\u003e",
"operationId": "OAuthController.Logout", "operationId": "OAuthController.GetLogout",
"parameters": [ "parameters": [
{
"in": "header",
"name": "Authorization",
"description": "auth token",
"type": "string"
},
{ {
"in": "query", "in": "query",
"name": "client_id", "name": "logout_challenge",
"description": "the client_id you want to get", "description": "The logout challenge from Hydra",
"required": true, "required": true,
"type": "string" "type": "string"
} }
], ],
"responses": { "responses": {
"200": { "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": [ "tags": [
"oc-auth/controllersOAuthController" "oc-auth/controllersOAuthController"
], ],
"description": "introspect token\n\u003cbr\u003e", "description": "Exchange a refresh_token for a new token set\n\u003cbr\u003e",
"operationId": "OAuthController.Introspection", "operationId": "OAuthController.Refresh",
"parameters": [ "parameters": [
{ {
"in": "body", "in": "body",
"name": "body", "name": "body",
"description": "The token info", "description": "refresh_token and client_id",
"required": true, "required": true,
"schema": { "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": { "responses": {
"200": { "200": {
"description": "{string}" "description": "",
"schema": {
"$ref": "#/definitions/auth_connectors.TokenResponse"
}
},
"401": {
"description": "invalid refresh token"
} }
} }
} }
@@ -699,19 +798,152 @@
} }
}, },
"definitions": { "definitions": {
"models.Token": { "2111.0xc0004ce750.false": {
"title": "Token", "title": "false",
"type": "object" "type": "object"
}, },
"models.workflow": { "3850.0xc0004ce930.false": {
"title": "workflow", "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" "type": "object"
} }
}, },
"tags": [ "tags": [
{ {
"name": "oc-auth/controllersOAuthController", "name": "oc-auth/controllersOAuthController",
"description": "Operations about auth\n" "description": "OAuthController handles OAuth2 login/consent provider endpoints\n"
}, },
{ {
"name": "group", "name": "group",

View File

@@ -12,18 +12,41 @@ info:
url: https://www.gnu.org/licenses/agpl-3.0.html url: https://www.gnu.org/licenses/agpl-3.0.html
basePath: /oc/ basePath: /oc/
paths: paths:
/consent:
get:
tags:
- oc-auth/controllersOAuthController
description: |-
Hydra redirects here with a consent_challenge. Auto-accepts consent with user permissions.
<br>
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: /forward:
get: get:
tags: tags:
- oc-auth/controllersOAuthController - oc-auth/controllersOAuthController
description: |- description: |-
auth forward Forward auth for Traefik validates JWT via Hydra introspection
<br> <br>
operationId: OAuthController.AuthForward operationId: OAuthController.AuthForward
parameters: parameters:
- in: header - in: header
name: Authorization name: Authorization
description: auth token description: Bearer token
type: string type: string
responses: responses:
"200": "200":
@@ -164,61 +187,110 @@ paths:
tags: tags:
- oc-auth/controllersOAuthController - oc-auth/controllersOAuthController
description: |- description: |-
introspect token Introspect a token respects Hydra's response
<br> <br>
operationId: OAuthController.Introspection operationId: OAuthController.Introspect
parameters: parameters:
- in: header - in: header
name: Authorization name: Authorization
description: auth token description: Bearer token
type: string type: string
responses: responses:
"200": "200":
description: '{string}' description: ""
schema:
$ref: '#/definitions/auth_connectors.IntrospectResult'
/login: /login:
get:
tags:
- oc-auth/controllersOAuthController
description: |-
Hydra redirects here with a login_challenge. Returns challenge info or auto-accepts if session exists.
<br>
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: post:
tags: tags:
- oc-auth/controllersOAuthController - oc-auth/controllersOAuthController
description: |- description: |-
authenticate user Authenticate user via LDAP and accept Hydra login challenge
<br> <br>
operationId: OAuthController.Login operationId: OAuthController.PostLogin
parameters: parameters:
- in: body - in: body
name: body name: body
description: The workflow content description: Login credentials and challenge
required: true required: true
schema: 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.
<br>
operationId: OAuthController.GetLogout
parameters:
- in: query - in: query
name: client_id name: logout_challenge
description: the client_id you want to get description: The logout challenge from Hydra
required: true required: true
type: string type: string
responses: responses:
"200": "200":
description: '{string}' description: ""
/logout: schema:
$ref: '#/definitions/auth_connectors.Redirect'
"400":
description: missing logout_challenge
"500":
description: internal error
delete: delete:
tags: tags:
- oc-auth/controllersOAuthController - oc-auth/controllersOAuthController
description: |- description: |-
unauthenticate user Revoke an OAuth2 token
<br> <br>
operationId: OAuthController.Logout operationId: OAuthController.Logout
parameters: parameters:
- in: header - in: header
name: Authorization name: Authorization
description: auth token description: Bearer token
type: string type: string
- in: query - in: query
name: client_id name: client_id
description: the client_id you want to get description: The client_id
required: true required: true
type: string type: string
responses: responses:
"200": "200":
description: '{string}' description: ""
schema:
$ref: '#/definitions/auth_connectors.Token'
/permission/: /permission/:
get: get:
tags: tags:
@@ -350,24 +422,23 @@ paths:
tags: tags:
- oc-auth/controllersOAuthController - oc-auth/controllersOAuthController
description: |- description: |-
introspect token Exchange a refresh_token for a new token set
<br> <br>
operationId: OAuthController.Introspection operationId: OAuthController.Refresh
parameters: parameters:
- in: body - in: body
name: body name: body
description: The token info description: refresh_token and client_id
required: true required: true
schema: 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: responses:
"200": "200":
description: '{string}' description: ""
schema:
$ref: '#/definitions/auth_connectors.TokenResponse'
"401":
description: invalid refresh token
/role/: /role/:
get: get:
tags: tags:
@@ -522,16 +593,106 @@ paths:
"200": "200":
description: "" description: ""
definitions: 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 title: Token
type: object type: object
models.workflow: properties:
title: workflow 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 type: object
tags: tags:
- name: oc-auth/controllersOAuthController - name: oc-auth/controllersOAuthController
description: | description: |
Operations about auth OAuthController handles OAuth2 login/consent provider endpoints
- name: group - name: group
description: | description: |
Operations about auth Operations about auth