Skip to content

Commit fa40bbc

Browse files
Add auth tests and update docs
- Add tests - Update env.example - Removes the fallback that injected TPP-client-id from BFF_PROXY__PLACEHOLDER_CLIENT_ID on every proxied request.
1 parent 5e58e79 commit fa40bbc

11 files changed

Lines changed: 779 additions & 138 deletions

File tree

portal/backend/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ BFF_PROXY__MAX_REQUEST_BYTES=1048576
4040
BFF_PROXY__MAX_RESPONSE_BYTES=10485760
4141
BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"]
4242

43-
# Placeholder identity mode (Phase 2 only; must be false in production)
43+
# Placeholder identity mode (For dev/test only; must be false in production)
4444
BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false
4545
BFF_PROXY__PLACEHOLDER_USER_ID=
4646
BFF_PROXY__PLACEHOLDER_ORG_ID=
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package me
2+
3+
import "testing"
4+
5+
func TestIsConsentOwnedByUser(t *testing.T) {
6+
body := []byte(`{"authorizations":[{"userId":"user-1"}]}`)
7+
if !IsConsentOwnedByUser(body, "user-1") {
8+
t.Fatal("expected matching authorization to be owned")
9+
}
10+
if IsConsentOwnedByUser(body, "user-2") {
11+
t.Fatal("expected foreign authorization to be rejected")
12+
}
13+
if IsConsentOwnedByUser([]byte(`{"authorizations":[]}`), "user-1") {
14+
t.Fatal("expected missing authorization to be rejected")
15+
}
16+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package proxy
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"testing"
7+
)
8+
9+
func TestAPIScopePolicy(t *testing.T) {
10+
tests := []struct{ method, path, want string }{
11+
{http.MethodGet, "/api/consents", "portal:consents:read:any"},
12+
{http.MethodPost, "/api/consents", "portal:consents:write:any"},
13+
{http.MethodGet, "/api/consent-elements", "portal:elements:read"},
14+
{http.MethodDelete, "/api/consent-elements/id", "portal:elements:write"},
15+
{http.MethodGet, "/api/consent-purposes/id", "portal:purposes:read"},
16+
{http.MethodPut, "/api/consent-purposes/id", "portal:purposes:write"},
17+
}
18+
for _, tt := range tests {
19+
if got := apiScope(&http.Request{Method: tt.method, URL: mustURL(tt.path)}); got != tt.want {
20+
t.Errorf("%s %s: got %q, want %q", tt.method, tt.path, got, tt.want)
21+
}
22+
}
23+
}
24+
25+
func mustURL(path string) *url.URL { u, _ := url.Parse(path); return u }

portal/backend/internal/proxy/service.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,6 @@ func (s *Service) setTrustedHeaders(incoming *http.Request, outgoing *http.Reque
376376
}
377377
if trustedClientID != "" {
378378
outgoing.Header.Set("TPP-client-id", trustedClientID)
379-
} else if s.cfg.PlaceholderClientID != "" {
380-
outgoing.Header.Set("TPP-client-id", s.cfg.PlaceholderClientID)
381379
}
382380
correlationID := incoming.Header.Get("X-Correlation-ID")
383381
if correlationID == "" {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"encoding/base64"
8+
"encoding/json"
9+
"math/big"
10+
"net/http"
11+
"net/http/httptest"
12+
"sync"
13+
"sync/atomic"
14+
"testing"
15+
"time"
16+
17+
"github.com/golang-jwt/jwt/v5"
18+
"github.com/wso2/openfgc/portal/backend/internal/system/config"
19+
)
20+
21+
type testJWKS struct {
22+
mu sync.RWMutex
23+
key *rsa.PrivateKey
24+
kid string
25+
fail bool
26+
hits atomic.Int64
27+
}
28+
29+
func newTestValidator(t *testing.T) (*Validator, *testJWKS, func(jwt.MapClaims, string) string) {
30+
t.Helper()
31+
state := &testJWKS{kid: "key-1"}
32+
var err error
33+
state.key, err = rsa.GenerateKey(rand.Reader, 2048)
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
var server *httptest.Server
38+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
state.hits.Add(1)
40+
state.mu.RLock()
41+
defer state.mu.RUnlock()
42+
if state.fail {
43+
http.Error(w, "unavailable", http.StatusServiceUnavailable)
44+
return
45+
}
46+
if r.URL.Path == "/.well-known/openid-configuration" {
47+
_ = json.NewEncoder(w).Encode(discovery{JWKSURI: server.URL + "/keys"})
48+
return
49+
}
50+
if r.URL.Path == "/keys" {
51+
n := base64.RawURLEncoding.EncodeToString(state.key.PublicKey.N.Bytes())
52+
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(state.key.PublicKey.E)).Bytes())
53+
_ = json.NewEncoder(w).Encode(jwks{Keys: []jwk{{KID: state.kid, KTY: "RSA", N: n, E: e}}})
54+
return
55+
}
56+
http.NotFound(w, r)
57+
}))
58+
cfg := config.AuthConfig{IssuerURL: server.URL, ResourceAudience: "consent-api", AllowedAlgorithms: []string{"RS256"}, JWKSTTL: time.Hour, JWKSRefreshTimeout: time.Second, ClockSkew: 0}
59+
sign := func(claims jwt.MapClaims, kid string) string {
60+
state.mu.RLock()
61+
key := state.key
62+
state.mu.RUnlock()
63+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
64+
token.Header["kid"] = kid
65+
raw, signErr := token.SignedString(key)
66+
if signErr != nil {
67+
t.Fatal(signErr)
68+
}
69+
return raw
70+
}
71+
return NewValidator(cfg), state, sign
72+
}
73+
74+
func validClaims(issuer string) jwt.MapClaims {
75+
now := time.Now()
76+
return jwt.MapClaims{"iss": issuer, "aud": "consent-api", "sub": "user-1", "org_id": "org-1", "scope": "portal:consents:read:self", "iat": now.Unix(), "nbf": now.Unix(), "exp": now.Add(time.Hour).Unix()}
77+
}
78+
79+
func TestValidatorRejectsInvalidClaims(t *testing.T) {
80+
v, _, sign := newTestValidator(t)
81+
base := validClaims(v.cfg.IssuerURL)
82+
tests := []struct {
83+
name string
84+
mutate func(jwt.MapClaims)
85+
alg string
86+
}{
87+
{"wrong issuer", func(c jwt.MapClaims) { c["iss"] = "https://other" }, "RS256"},
88+
{"wrong audience", func(c jwt.MapClaims) { c["aud"] = "other" }, "RS256"},
89+
{"expired", func(c jwt.MapClaims) { c["exp"] = time.Now().Add(-time.Minute).Unix() }, "RS256"},
90+
{"not yet valid", func(c jwt.MapClaims) { c["nbf"] = time.Now().Add(time.Minute).Unix() }, "RS256"},
91+
{"missing sub", func(c jwt.MapClaims) { delete(c, "sub") }, "RS256"},
92+
{"missing org", func(c jwt.MapClaims) { delete(c, "org_id") }, "RS256"},
93+
}
94+
for _, tt := range tests {
95+
t.Run(tt.name, func(t *testing.T) {
96+
c := jwt.MapClaims{}
97+
for k, v := range base {
98+
c[k] = v
99+
}
100+
tt.mutate(c)
101+
if _, err := v.Validate(context.Background(), sign(c, "key-1")); err == nil {
102+
t.Fatal("expected validation failure")
103+
}
104+
})
105+
}
106+
}
107+
108+
func TestValidatorRejectsInvalidSignatureAndTokenType(t *testing.T) {
109+
v, _, sign := newTestValidator(t)
110+
other, err := rsa.GenerateKey(rand.Reader, 2048)
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, validClaims(v.cfg.IssuerURL))
115+
token.Header["kid"] = "key-1"
116+
raw, err := token.SignedString(other)
117+
if err != nil {
118+
t.Fatal(err)
119+
}
120+
if _, err := v.Validate(context.Background(), raw); err == nil {
121+
t.Fatal("expected invalid signature")
122+
}
123+
v.cfg.RequireAccessTokenType = true
124+
v.cfg.TokenTypeClaim, v.cfg.AccessTokenType = "token_type", "access_token"
125+
claims := validClaims(v.cfg.IssuerURL)
126+
claims["token_type"] = "id_token"
127+
if _, err := v.Validate(context.Background(), sign(claims, "key-1")); err == nil {
128+
t.Fatal("expected token type rejection")
129+
}
130+
}
131+
132+
func TestValidatorUsesCachedJWKS(t *testing.T) {
133+
v, state, sign := newTestValidator(t)
134+
raw := sign(validClaims(v.cfg.IssuerURL), "key-1")
135+
if _, err := v.Validate(context.Background(), raw); err != nil {
136+
t.Fatal(err)
137+
}
138+
first := state.hits.Load()
139+
if _, err := v.Validate(context.Background(), raw); err != nil {
140+
t.Fatal(err)
141+
}
142+
second := state.hits.Load()
143+
if second != first {
144+
t.Fatalf("expected cache hit without network refresh: %d -> %d", first, second)
145+
}
146+
}
147+
148+
func TestValidatorParsesScopesAndRejectsUnknownAlgorithm(t *testing.T) {
149+
v, state, sign := newTestValidator(t)
150+
p, err := v.Validate(context.Background(), sign(validClaims(v.cfg.IssuerURL), "key-1"))
151+
if err != nil || len(p.Scopes) != 1 {
152+
t.Fatalf("expected valid scoped principal, got %#v, %v", p, err)
153+
}
154+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, validClaims(v.cfg.IssuerURL))
155+
token.Header["kid"] = "key-1"
156+
raw, _ := token.SignedString([]byte("secret"))
157+
if _, err := v.Validate(context.Background(), raw); err == nil {
158+
t.Fatal("expected algorithm rejection")
159+
}
160+
_ = state
161+
}
162+
163+
func TestValidatorRefreshesUnknownKIDAndKeepsLastKnownGoodKey(t *testing.T) {
164+
v, state, sign := newTestValidator(t)
165+
if _, err := v.Validate(context.Background(), sign(validClaims(v.cfg.IssuerURL), "key-1")); err != nil {
166+
t.Fatal(err)
167+
}
168+
state.mu.Lock()
169+
next, _ := rsa.GenerateKey(rand.Reader, 2048)
170+
state.key, state.kid = next, "key-2"
171+
state.mu.Unlock()
172+
if _, err := v.Validate(context.Background(), sign(validClaims(v.cfg.IssuerURL), "key-2")); err != nil {
173+
t.Fatalf("expected rotation refresh: %v", err)
174+
}
175+
state.mu.Lock()
176+
state.fail = true
177+
state.mu.Unlock()
178+
if _, err := v.Validate(context.Background(), sign(validClaims(v.cfg.IssuerURL), "key-2")); err != nil {
179+
t.Fatalf("expected cached key during refresh failure: %v", err)
180+
}
181+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
9+
)
10+
11+
func TestAuthenticateRejectsInvalidBearerHeaders(t *testing.T) {
12+
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
13+
h := Authenticate(next, nil, IdentityOptions{})
14+
tests := []struct {
15+
name string
16+
headers []string
17+
}{
18+
{"missing", nil}, {"malformed", []string{"Basic abc"}}, {"duplicate", []string{"Bearer one", "Bearer two"}},
19+
}
20+
for _, tt := range tests {
21+
t.Run(tt.name, func(t *testing.T) {
22+
r := httptest.NewRequest(http.MethodGet, "/me/consents", nil)
23+
for _, value := range tt.headers {
24+
r.Header.Add("Authorization", value)
25+
}
26+
w := httptest.NewRecorder()
27+
h.ServeHTTP(w, r)
28+
if w.Code != http.StatusUnauthorized {
29+
t.Fatalf("expected 401, got %d", w.Code)
30+
}
31+
if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" {
32+
t.Fatalf("expected WWW-Authenticate Bearer, got %q", got)
33+
}
34+
})
35+
}
36+
}
37+
38+
func TestRequireScopeReturnsForbiddenWithoutValidatedPrincipal(t *testing.T) {
39+
h := RequireScope(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), func(*http.Request) string { return "portal:consents:read:self" })
40+
w := httptest.NewRecorder()
41+
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/me/consents", nil))
42+
if w.Code != http.StatusForbidden {
43+
t.Fatalf("expected 403, got %d", w.Code)
44+
}
45+
}
46+
47+
func TestPlaceholderAuthenticationWritesUserIdentity(t *testing.T) {
48+
var got systemcontext.UserIdentity
49+
h := Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+
var ok bool
51+
got, ok = systemcontext.UserIdentityFromContext(r.Context())
52+
if !ok {
53+
t.Fatal("missing identity")
54+
}
55+
w.WriteHeader(http.StatusNoContent)
56+
}), nil, IdentityOptions{PlaceholderModeEnabled: true, PlaceholderUserID: "user-1", PlaceholderOrgID: "org-1"})
57+
w := httptest.NewRecorder()
58+
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/me/consents", nil))
59+
if w.Code != http.StatusNoContent || got.UserID != "user-1" || got.OrgID != "org-1" {
60+
t.Fatalf("unexpected result: status=%d identity=%#v", w.Code, got)
61+
}
62+
}

0 commit comments

Comments
 (0)