diff --git a/conf/config.go b/conf/config.go index cfbd2db..2ebe747 100644 --- a/conf/config.go +++ b/conf/config.go @@ -30,6 +30,15 @@ type Config struct { PermissionConnectorPort string PermissionConnectorAdminPort string + // OAuthRedirectURI is the registered OAuth2 redirect_uri (frontend callback URL). + // After a successful login, Hydra redirects here with the authorization code. + // The original protected URL is passed as the state parameter. + AdminOrigin string + Origin string + + OAuthRedirectURI string + OAdminAuthRedirectURI string + Local bool } diff --git a/controllers/oauth2.go b/controllers/oauth2.go index 9da06be..9470ef1 100644 --- a/controllers/oauth2.go +++ b/controllers/oauth2.go @@ -374,48 +374,104 @@ func (o *OAuthController) Introspect() { o.ServeJSON() } +// whitelist lists path segments of oc-auth's own challenge endpoints. +// These require no token — the challenge is passed as a query parameter by Hydra. var whitelist = []string{ + "/public/", + "/version", + "/status", "/login", + "/logout", "/refresh", "/introspect", "/consent", } // @Title AuthForward -// @Description Forward auth for Traefik — validates JWT via Hydra introspection +// @Description Forward auth for Traefik — validates JWT via Hydra introspection. +// Only requests from our own peer (SELF) are authorized. +// Routes in pathWhitelist bypass all checks (with or without token). +// Routes in whitelist bypass the token check (oc-auth own challenge endpoints). +// On missing/invalid token: 302 to Hydra authorization URL (restart OAuth2 flow). +// On wrong peer: 401 (network/config issue, no redirect). +// On valid token but insufficient permissions: 403. +// On success: 200 so Traefik forwards the request to the target route. // @Param Authorization header string false "Bearer token" // @Success 200 {string} // @router /forward [get] func (o *OAuthController) InternalAuthForward() { + fmt.Println("InternalAuthForward") + uri := o.Ctx.Request.Header.Get("X-Forwarded-Uri") + for _, w := range whitelist { + if strings.Contains(uri, w) { + fmt.Println("WHITELIST", w) + o.Ctx.ResponseWriter.WriteHeader(http.StatusOK) + return + } + } + + origin, publicKey, external := o.extractOrigin(o.Ctx.Request) + reqToken := o.Ctx.Request.Header.Get("Authorization") if reqToken == "" { - for _, w := range whitelist { - if strings.Contains(o.Ctx.Request.Header.Get("X-Forwarded-Uri"), w) { - o.Ctx.ResponseWriter.WriteHeader(200) - o.ServeJSON() - return - } - } - o.Ctx.ResponseWriter.WriteHeader(401) - o.ServeJSON() + // Step 1: no token — allow oc-auth's own challenge endpoints (no token needed). + // No token and not a whitelisted path → restart OAuth2 flow. + fmt.Println("NO TOKEN") + o.redirectToLogin(origin) return } + + // Step 2: extract Bearer token — malformed header treated as missing token. splitToken := strings.Split(reqToken, "Bearer ") - if len(splitToken) < 2 { - reqToken = "" - } else { - reqToken = splitToken[1] + if len(splitToken) < 2 || splitToken[1] == "" { + fmt.Println("MALFORMED BEARER") + o.redirectToLogin(origin) + return } - origin, publicKey, external := o.extractOrigin(o.Ctx.Request) - if !infrastructure.GetAuthConnector().CheckAuthForward( + reqToken = splitToken[1] + + // Step 3: resolve the calling peer — only our own peer (SELF) is authorized. + // A non-SELF or unknown peer is a network/config issue, not a login problem → 401. + if external || origin == "" || publicKey == "" { + fmt.Println("Unauthorized", external, origin, publicKey) + o.Ctx.ResponseWriter.WriteHeader(http.StatusUnauthorized) + return + } + + // Step 4: introspect via Hydra then check permissions via Keto. + // 401 → token inactive/invalid, user must re-authenticate → restart OAuth2 flow. + // 403 → token valid, but permissions denied → forbidden. + // 200 → all good, let Traefik forward to the target route. + switch infrastructure.GetAuthConnector().CheckAuthForward( reqToken, publicKey, origin, o.Ctx.Request.Header.Get("X-Forwarded-Method"), - o.Ctx.Request.Header.Get("X-Forwarded-Uri"), external) && origin != "" && publicKey != "" { - o.Ctx.ResponseWriter.WriteHeader(401) - o.ServeJSON() - return + uri, external) { + case http.StatusOK: + fmt.Println("OK") + o.Ctx.ResponseWriter.WriteHeader(http.StatusOK) + case http.StatusForbidden: + fmt.Println("StatusForbidden") + o.Ctx.ResponseWriter.WriteHeader(http.StatusForbidden) + default: + fmt.Println("redirectToLogin UNAUTHORIZED") + // 401 or unexpected status → token likely expired, restart the OAuth2 flow. + o.redirectToLogin(origin) } - o.ServeJSON() +} + +// redirectToLogin redirects the client to Hydra's authorization endpoint to start a fresh +// OAuth2 flow. The original request URL is passed as the state parameter so the frontend +// can redirect back after successful authentication. +func (o *OAuthController) redirectToLogin(origin string) { + cfg := conf.GetConfig() + + if strings.Contains(origin, cfg.AdminOrigin) { + o.Ctx.ResponseWriter.Header().Set("Location", cfg.OAdminAuthRedirectURI) + } else { + o.Ctx.ResponseWriter.Header().Set("Location", cfg.OAuthRedirectURI) + } + + o.Ctx.ResponseWriter.WriteHeader(http.StatusFound) } func (o *OAuthController) extractOrigin(request *http.Request) (string, string, bool) { diff --git a/docker-compose.yml b/docker-compose.yml index c0ac9cc..bf43dd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,15 @@ services: container_name: oc-auth labels: - "traefik.enable=true" - - "traefik.http.routers.auth.entrypoints=web" - - "traefik.http.routers.auth.rule=PathPrefix(`/auth`)" - - "traefik.http.middlewares.auth-rewrite.replacepathregex.regex=^/auth(.*)" - - "traefik.http.middlewares.auth-rewrite.replacepathregex.replacement=/oc$$1" - - "traefik.http.routers.auth.middlewares=auth-rewrite" - - "traefik.http.services.auth.loadbalancer.server.port=8080" - - "traefik.http.middlewares.auth.forwardauth.address=http://oc-auth:8080/oc/forward" + - "traefik.http.routers.auth-sec.entrypoints=web" + - "traefik.http.routers.auth-sec.rule=PathPrefix(`/auth/`)" + - "traefik.http.middlewares.auth-sec-rewrite.replacepathregex.regex=^/auth(.*)" + - "traefik.http.middlewares.auth-sec-rewrite.replacepathregex.replacement=/oc$$1" + - "traefik.http.services.auth-sec.loadbalancer.server.port=8080" + - "traefik.http.routers.auth-sec.middlewares=auth-sec-rewrite,auth-auth-sec" + - "traefik.http.middlewares.auth-auth-sec.forwardauth.address=http://hydra:4444/oauth2/auth" + - "traefik.http.middlewares.auth-auth-sec.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.auth-auth-sec.forwardauth.authResponseHeaders=X-Auth-Request-User,X-Auth-Request-Email" environment: LDAP_ENDPOINTS: ldap:389 LDAP_BINDDN: cn=admin,dc=example,dc=com diff --git a/infrastructure/auth_connector/auth_connector.go b/infrastructure/auth_connector/auth_connector.go index add72b9..7130bc3 100644 --- a/infrastructure/auth_connector/auth_connector.go +++ b/infrastructure/auth_connector/auth_connector.go @@ -27,8 +27,12 @@ type AuthConnector interface { 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 validates the token and permissions for a forward auth request. + // Returns an HTTP status code: + // 200 — token active and permissions granted + // 401 — token missing, invalid, or inactive → caller should redirect to login + // 403 — token valid but permissions denied → caller should return forbidden + CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) int } // Token is the unified token response returned to clients diff --git a/infrastructure/auth_connector/hydra_connector.go b/infrastructure/auth_connector/hydra_connector.go index 96c8962..3186a4c 100644 --- a/infrastructure/auth_connector/hydra_connector.go +++ b/infrastructure/auth_connector/hydra_connector.go @@ -299,61 +299,61 @@ func (h *HydraConnector) RefreshToken(refreshToken string, clientID string) (*To 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 { +// CheckAuthForward validates a JWT token for forward auth (Traefik integration). +// It introspects the token via Hydra then checks permissions via Keto. +// Only requests from our own peer (external == false) are accepted. +// Returns 200 (OK), 401 (token inactive/invalid → redirect to login), or 403 (permission denied). +func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) int { if forward == "" || method == "" { - return false + return http.StatusUnauthorized + } + // Defense in depth: only SELF peer requests are allowed. + if external { + return http.StatusUnauthorized } logger := oclib.GetLogger() - // Introspect the token via Hydra to get claims + // Introspect the token via Hydra. + // An inactive or invalid token means the user must re-authenticate → 401. result, err := h.Introspect(reqToken) if err != nil || !result.Active { if err != nil { logger.Error().Msg("Forward auth introspect failed: " + err.Error()) } - return false + return http.StatusUnauthorized } - // Extract claims from the introspection result's extra data - // Hydra puts consent session's access_token data in the "ext" field of introspection + // Build session claims from Hydra's introspection "ext" field. + // Hydra injects the consent session's access_token data there. var sessionClaims claims.Claims + sessionClaims.Session.AccessToken = make(map[string]interface{}) + sessionClaims.Session.IDToken = make(map[string]interface{}) 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 } } - // 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 + // For SELF peer requests skip the signature check (internal traffic). 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 { + if 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 } } + // Check permissions via Keto. + // A valid token with insufficient permissions → 403 (authenticated, not authorized). ok, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, sessionClaims, publicKey, external) if err != nil { - logger.Error().Msg("Failed to decode claims: " + err.Error()) + logger.Error().Msg("Failed to decode claims in forward auth: " + err.Error()) + return http.StatusForbidden } - return ok + if !ok { + return http.StatusForbidden + } + return http.StatusOK } // extractBearerToken extracts the token from a "Bearer xxx" Authorization header value diff --git a/infrastructure/perms_connectors/keto_connector.go b/infrastructure/perms_connectors/keto_connector.go index 81be057..b7d7f56 100644 --- a/infrastructure/perms_connectors/keto_connector.go +++ b/infrastructure/perms_connectors/keto_connector.go @@ -370,17 +370,20 @@ func (k KetoConnector) createRelationShip(object string, relation string, subjec log.Error().Msgf("createRelationShip unmarshal error: %s, err=%v", string(b), err) return nil, 500, err } - perm := &Permission{ - Object: data["object"].(string), - Relation: data["relation"].(string), - Subject: data["subject_id"].(string), - } - if data["subject_set"] != nil { - sub := data["subject_set"].(map[string]interface{}) - perm.SubPermission = &Permission{ - Object: sub["object"].(string), - Relation: sub["relation"].(string), - Subject: sub["subject_id"].(string), + perm := &Permission{} + if data != nil { + perm = &Permission{ + Object: data["object"].(string), + Relation: data["relation"].(string), + Subject: data["subject_id"].(string), + } + if data["subject_set"] != nil { + sub := data["subject_set"].(map[string]interface{}) + perm.SubPermission = &Permission{ + Object: sub["object"].(string), + Relation: sub["relation"].(string), + Subject: sub["subject_id"].(string), + } } } return perm, 200, nil diff --git a/main.go b/main.go index cb4263a..8f641cd 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "oc-auth/infrastructure" auth_connectors "oc-auth/infrastructure/auth_connector" _ "oc-auth/routers" + "strings" "time" oclib "cloud.o-forge.io/core/oc-lib" @@ -40,9 +41,15 @@ func main() { conf.GetConfig().PermissionConnectorReadHost = o.GetStringDefault("PERMISSION_CONNECTOR_READ_HOST", "keto") conf.GetConfig().PermissionConnectorPort = o.GetStringDefault("PERMISSION_CONNECTOR_PORT", "4466") conf.GetConfig().PermissionConnectorAdminPort = o.GetStringDefault("PERMISSION_CONNECTOR_ADMIN_PORT", "4467") + + conf.GetConfig().Origin = o.GetStringDefault("ADMIN_ORIGIN", "http://localhost:8000") + conf.GetConfig().AdminOrigin = o.GetStringDefault("ADMIN_ORIGIN", "http://localhost:8001") + + conf.GetConfig().OAuthRedirectURI = o.GetStringDefault("OAUTH_REDIRECT_URI", "http://google.com") + conf.GetConfig().OAdminAuthRedirectURI = o.GetStringDefault("ADMIN_OAUTH_REDIRECT_URI", "http://chatgpt.com") conf.GetConfig().Local = o.GetBoolDefault("LOCAL", true) - // config LDAP + // config LDAPauth conf.GetConfig().SourceMode = o.GetStringDefault("SOURCE_MODE", "ldap") conf.GetConfig().LDAPEndpoints = o.GetStringDefault("LDAP_ENDPOINTS", "ldap:389") conf.GetConfig().LDAPBindDN = o.GetStringDefault("LDAP_BINDDN", "cn=admin,dc=example,dc=com") @@ -100,17 +107,23 @@ func discovery() { logger.Info().Msg("Starting permission discovery") _, _, err := conn.CreateRole(conf.GetConfig().AdminRole) if err != nil { - logger.Error().Msg("Failed to create admin role, retrying in 10s: " + err.Error()) - time.Sleep(10 * time.Second) - continue + if !strings.Contains(err.Error(), "already exist") { + logger.Error().Msg("Failed to create admin role, retrying in 10s: " + err.Error()) + time.Sleep(10 * time.Second) + continue + } + } + if _, _, err := conn.BindRole(conf.GetConfig().AdminRole, "admin"); err != nil { + logger.Error().Msg("Failed to admin bind role: " + err.Error()) } - conn.BindRole(conf.GetConfig().AdminRole, "admin") addPermissions := func(m tools.NATSResponse) { var resp map[string][]interface{} json.Unmarshal(m.Payload, &resp) for k, v := range resp { for _, p := range v { - conn.CreatePermission(k, p.(string), true) + if _, _, err := conn.CreatePermission(k, p.(string), true); err != nil { + logger.Error().Msg("Failed to admin create permission: " + err.Error()) + } } } } diff --git a/oc-auth b/oc-auth index a32f2c9..c3480d3 100755 Binary files a/oc-auth and b/oc-auth differ diff --git a/swagger/swagger.json b/swagger/swagger.json index b22c3bd..da256bc 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -52,7 +52,7 @@ "tags": [ "oc-auth/controllersOAuthController" ], - "description": "Forward auth for Traefik — validates JWT via Hydra introspection\n\u003cbr\u003e", + "description": "Forward auth for Traefik — validates JWT via Hydra introspection.\n\u003cbr\u003e", "operationId": "OAuthController.AuthForward", "parameters": [ { @@ -798,11 +798,11 @@ } }, "definitions": { - "2111.0xc0004ce750.false": { + "2432.0xc000460e70.false": { "title": "false", "type": "object" }, - "3850.0xc0004ce930.false": { + "4171.0xc000461050.false": { "title": "false", "type": "object" }, @@ -821,7 +821,7 @@ "format": "int64" }, "ext": { - "$ref": "#/definitions/3850.0xc0004ce930.false" + "$ref": "#/definitions/4171.0xc000461050.false" }, "scope": { "type": "string" @@ -842,7 +842,7 @@ "type": "string" }, "client": { - "$ref": "#/definitions/2111.0xc0004ce750.false" + "$ref": "#/definitions/2432.0xc000460e70.false" }, "request_url": { "type": "string" diff --git a/swagger/swagger.yml b/swagger/swagger.yml index b307612..aff15bf 100644 --- a/swagger/swagger.yml +++ b/swagger/swagger.yml @@ -40,7 +40,7 @@ paths: tags: - oc-auth/controllersOAuthController description: |- - Forward auth for Traefik — validates JWT via Hydra introspection + Forward auth for Traefik — validates JWT via Hydra introspection.
operationId: OAuthController.AuthForward parameters: @@ -593,10 +593,10 @@ paths: "200": description: "" definitions: - 2111.0xc0004ce750.false: + 2432.0xc000460e70.false: title: "false" type: object - 3850.0xc0004ce930.false: + 4171.0xc000461050.false: title: "false" type: object auth_connectors.IntrospectResult: @@ -611,7 +611,7 @@ definitions: type: integer format: int64 ext: - $ref: '#/definitions/3850.0xc0004ce930.false' + $ref: '#/definitions/4171.0xc000461050.false' scope: type: string sub: @@ -625,7 +625,7 @@ definitions: challenge: type: string client: - $ref: '#/definitions/2111.0xc0004ce750.false' + $ref: '#/definitions/2432.0xc000460e70.false' request_url: type: string session_id: