Complete and refine OAuth + Traeffik Restriction

This commit is contained in:
mr
2026-02-20 10:30:34 +01:00
parent 078aae8172
commit 979747e288
10 changed files with 171 additions and 84 deletions

View File

@@ -30,6 +30,15 @@ type Config struct {
PermissionConnectorPort string PermissionConnectorPort string
PermissionConnectorAdminPort 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 Local bool
} }

View File

@@ -374,48 +374,104 @@ func (o *OAuthController) Introspect() {
o.ServeJSON() 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{ var whitelist = []string{
"/public/",
"/version",
"/status",
"/login", "/login",
"/logout",
"/refresh", "/refresh",
"/introspect", "/introspect",
"/consent", "/consent",
} }
// @Title AuthForward // @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" // @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")
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") reqToken := o.Ctx.Request.Header.Get("Authorization")
if reqToken == "" { if reqToken == "" {
for _, w := range whitelist { // Step 1: no token — allow oc-auth's own challenge endpoints (no token needed).
if strings.Contains(o.Ctx.Request.Header.Get("X-Forwarded-Uri"), w) { // No token and not a whitelisted path → restart OAuth2 flow.
o.Ctx.ResponseWriter.WriteHeader(200) fmt.Println("NO TOKEN")
o.ServeJSON() o.redirectToLogin(origin)
return
}
}
o.Ctx.ResponseWriter.WriteHeader(401)
o.ServeJSON()
return return
} }
// Step 2: extract Bearer token — malformed header treated as missing token.
splitToken := strings.Split(reqToken, "Bearer ") splitToken := strings.Split(reqToken, "Bearer ")
if len(splitToken) < 2 { if len(splitToken) < 2 || splitToken[1] == "" {
reqToken = "" fmt.Println("MALFORMED BEARER")
} else { o.redirectToLogin(origin)
reqToken = splitToken[1] return
} }
origin, publicKey, external := o.extractOrigin(o.Ctx.Request) reqToken = splitToken[1]
if !infrastructure.GetAuthConnector().CheckAuthForward(
// 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, 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 != "" { uri, external) {
o.Ctx.ResponseWriter.WriteHeader(401) case http.StatusOK:
o.ServeJSON() fmt.Println("OK")
return 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) { func (o *OAuthController) extractOrigin(request *http.Request) (string, string, bool) {

View File

@@ -8,13 +8,15 @@ services:
container_name: oc-auth container_name: oc-auth
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.auth.entrypoints=web" - "traefik.http.routers.auth-sec.entrypoints=web"
- "traefik.http.routers.auth.rule=PathPrefix(`/auth`)" - "traefik.http.routers.auth-sec.rule=PathPrefix(`/auth/`)"
- "traefik.http.middlewares.auth-rewrite.replacepathregex.regex=^/auth(.*)" - "traefik.http.middlewares.auth-sec-rewrite.replacepathregex.regex=^/auth(.*)"
- "traefik.http.middlewares.auth-rewrite.replacepathregex.replacement=/oc$$1" - "traefik.http.middlewares.auth-sec-rewrite.replacepathregex.replacement=/oc$$1"
- "traefik.http.routers.auth.middlewares=auth-rewrite" - "traefik.http.services.auth-sec.loadbalancer.server.port=8080"
- "traefik.http.services.auth.loadbalancer.server.port=8080" - "traefik.http.routers.auth-sec.middlewares=auth-sec-rewrite,auth-auth-sec"
- "traefik.http.middlewares.auth.forwardauth.address=http://oc-auth:8080/oc/forward" - "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: environment:
LDAP_ENDPOINTS: ldap:389 LDAP_ENDPOINTS: ldap:389
LDAP_BINDDN: cn=admin,dc=example,dc=com LDAP_BINDDN: cn=admin,dc=example,dc=com

View File

@@ -27,8 +27,12 @@ type AuthConnector interface {
RevokeToken(token string, clientID string) error RevokeToken(token string, clientID string) error
RefreshToken(refreshToken string, clientID string) (*TokenResponse, error) RefreshToken(refreshToken string, clientID string) (*TokenResponse, error)
// Forward auth // CheckAuthForward validates the token and permissions for a forward auth request.
CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) bool // 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 // Token is the unified token response returned to clients

View File

@@ -299,61 +299,61 @@ func (h *HydraConnector) RefreshToken(refreshToken string, clientID string) (*To
return &result, nil return &result, nil
} }
// CheckAuthForward validates a JWT token for forward auth (Traefik integration) // 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 // It introspects the token via Hydra then checks permissions via Keto.
func (h *HydraConnector) CheckAuthForward(reqToken string, publicKey string, host string, method string, forward string, external bool) bool { // 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 == "" { 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() 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) result, err := h.Introspect(reqToken)
if err != nil || !result.Active { if err != nil || !result.Active {
if err != nil { if err != nil {
logger.Error().Msg("Forward auth introspect failed: " + err.Error()) logger.Error().Msg("Forward auth introspect failed: " + err.Error())
} }
return false return http.StatusUnauthorized
} }
// Extract claims from the introspection result's extra data // Build session claims from Hydra's introspection "ext" field.
// Hydra puts consent session's access_token data in the "ext" field of introspection // Hydra injects the consent session's access_token data there.
var sessionClaims claims.Claims var sessionClaims claims.Claims
if result.Extra != nil {
sessionClaims.Session.AccessToken = make(map[string]interface{}) sessionClaims.Session.AccessToken = make(map[string]interface{})
sessionClaims.Session.IDToken = make(map[string]interface{}) sessionClaims.Session.IDToken = make(map[string]interface{})
if result.Extra != nil {
for k, v := range result.Extra { for k, v := range result.Extra {
sessionClaims.Session.AccessToken[k] = v sessionClaims.Session.AccessToken[k] = v
} }
} }
// Also try to get id_token claims from the token if it's a JWT // For SELF peer requests skip the signature check (internal traffic).
// 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) pp := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", []string{}, nil).Search(nil, fmt.Sprintf("%v", peer.SELF.EnumIndex()), false)
if len(pp.Data) > 0 { if len(pp.Data) > 0 {
p := pp.Data[0].(*peer.Peer) p := pp.Data[0].(*peer.Peer)
// Re-sign for local verification if this is our own peer if p.PublicKey == publicKey {
if !external && p.PublicKey == publicKey {
sessionClaims.Session.IDToken["signature"] = "" 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) ok, err := claims.GetClaims().DecodeClaimsInToken(host, method, forward, sessionClaims, publicKey, external)
if err != nil { 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 // extractBearerToken extracts the token from a "Bearer xxx" Authorization header value

View File

@@ -370,7 +370,9 @@ func (k KetoConnector) createRelationShip(object string, relation string, subjec
log.Error().Msgf("createRelationShip unmarshal error: %s, err=%v", string(b), err) log.Error().Msgf("createRelationShip unmarshal error: %s, err=%v", string(b), err)
return nil, 500, err return nil, 500, err
} }
perm := &Permission{ perm := &Permission{}
if data != nil {
perm = &Permission{
Object: data["object"].(string), Object: data["object"].(string),
Relation: data["relation"].(string), Relation: data["relation"].(string),
Subject: data["subject_id"].(string), Subject: data["subject_id"].(string),
@@ -383,6 +385,7 @@ func (k KetoConnector) createRelationShip(object string, relation string, subjec
Subject: sub["subject_id"].(string), Subject: sub["subject_id"].(string),
} }
} }
}
return perm, 200, nil return perm, 200, nil
} }

19
main.go
View File

@@ -7,6 +7,7 @@ import (
"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"
"strings"
"time" "time"
oclib "cloud.o-forge.io/core/oc-lib" 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().PermissionConnectorReadHost = o.GetStringDefault("PERMISSION_CONNECTOR_READ_HOST", "keto")
conf.GetConfig().PermissionConnectorPort = o.GetStringDefault("PERMISSION_CONNECTOR_PORT", "4466") conf.GetConfig().PermissionConnectorPort = o.GetStringDefault("PERMISSION_CONNECTOR_PORT", "4466")
conf.GetConfig().PermissionConnectorAdminPort = o.GetStringDefault("PERMISSION_CONNECTOR_ADMIN_PORT", "4467") 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) conf.GetConfig().Local = o.GetBoolDefault("LOCAL", true)
// config LDAP // config LDAPauth
conf.GetConfig().SourceMode = o.GetStringDefault("SOURCE_MODE", "ldap") conf.GetConfig().SourceMode = o.GetStringDefault("SOURCE_MODE", "ldap")
conf.GetConfig().LDAPEndpoints = o.GetStringDefault("LDAP_ENDPOINTS", "ldap:389") conf.GetConfig().LDAPEndpoints = o.GetStringDefault("LDAP_ENDPOINTS", "ldap:389")
conf.GetConfig().LDAPBindDN = o.GetStringDefault("LDAP_BINDDN", "cn=admin,dc=example,dc=com") 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") logger.Info().Msg("Starting permission discovery")
_, _, err := conn.CreateRole(conf.GetConfig().AdminRole) _, _, err := conn.CreateRole(conf.GetConfig().AdminRole)
if err != nil { if err != nil {
if !strings.Contains(err.Error(), "already exist") {
logger.Error().Msg("Failed to create admin role, retrying in 10s: " + err.Error()) logger.Error().Msg("Failed to create admin role, retrying in 10s: " + err.Error())
time.Sleep(10 * time.Second) time.Sleep(10 * time.Second)
continue continue
} }
conn.BindRole(conf.GetConfig().AdminRole, "admin") }
if _, _, err := conn.BindRole(conf.GetConfig().AdminRole, "admin"); err != nil {
logger.Error().Msg("Failed to admin bind role: " + err.Error())
}
addPermissions := func(m tools.NATSResponse) { addPermissions := func(m tools.NATSResponse) {
var resp map[string][]interface{} var resp map[string][]interface{}
json.Unmarshal(m.Payload, &resp) json.Unmarshal(m.Payload, &resp)
for k, v := range resp { for k, v := range resp {
for _, p := range v { 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())
}
} }
} }
} }

BIN
oc-auth

Binary file not shown.

View File

@@ -52,7 +52,7 @@
"tags": [ "tags": [
"oc-auth/controllersOAuthController" "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", "operationId": "OAuthController.AuthForward",
"parameters": [ "parameters": [
{ {
@@ -798,11 +798,11 @@
} }
}, },
"definitions": { "definitions": {
"2111.0xc0004ce750.false": { "2432.0xc000460e70.false": {
"title": "false", "title": "false",
"type": "object" "type": "object"
}, },
"3850.0xc0004ce930.false": { "4171.0xc000461050.false": {
"title": "false", "title": "false",
"type": "object" "type": "object"
}, },
@@ -821,7 +821,7 @@
"format": "int64" "format": "int64"
}, },
"ext": { "ext": {
"$ref": "#/definitions/3850.0xc0004ce930.false" "$ref": "#/definitions/4171.0xc000461050.false"
}, },
"scope": { "scope": {
"type": "string" "type": "string"
@@ -842,7 +842,7 @@
"type": "string" "type": "string"
}, },
"client": { "client": {
"$ref": "#/definitions/2111.0xc0004ce750.false" "$ref": "#/definitions/2432.0xc000460e70.false"
}, },
"request_url": { "request_url": {
"type": "string" "type": "string"

View File

@@ -40,7 +40,7 @@ paths:
tags: tags:
- oc-auth/controllersOAuthController - oc-auth/controllersOAuthController
description: |- description: |-
Forward auth for Traefik validates JWT via Hydra introspection Forward auth for Traefik validates JWT via Hydra introspection.
<br> <br>
operationId: OAuthController.AuthForward operationId: OAuthController.AuthForward
parameters: parameters:
@@ -593,10 +593,10 @@ paths:
"200": "200":
description: "" description: ""
definitions: definitions:
2111.0xc0004ce750.false: 2432.0xc000460e70.false:
title: "false" title: "false"
type: object type: object
3850.0xc0004ce930.false: 4171.0xc000461050.false:
title: "false" title: "false"
type: object type: object
auth_connectors.IntrospectResult: auth_connectors.IntrospectResult:
@@ -611,7 +611,7 @@ definitions:
type: integer type: integer
format: int64 format: int64
ext: ext:
$ref: '#/definitions/3850.0xc0004ce930.false' $ref: '#/definitions/4171.0xc000461050.false'
scope: scope:
type: string type: string
sub: sub:
@@ -625,7 +625,7 @@ definitions:
challenge: challenge:
type: string type: string
client: client:
$ref: '#/definitions/2111.0xc0004ce750.false' $ref: '#/definitions/2432.0xc000460e70.false'
request_url: request_url:
type: string type: string
session_id: session_id: