Skip to content

Commit 2fa4366

Browse files
committed
fix: find profile card heuristic
1 parent 8f07df2 commit 2fa4366

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

containers/ingress-uma/auth/derived_resources.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package auth
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
7+
"io"
68
"net/http"
79
"net/url"
810
"path"
911
"strings"
12+
"sync"
13+
"time"
1014

1115
"github.com/sirupsen/logrus"
1216
)
@@ -21,6 +25,10 @@ type derivedResourceRequest struct {
2125
Sources []derivedSource `json:"sources"`
2226
}
2327

28+
var profileProbeClient = &http.Client{Timeout: 2 * time.Second}
29+
30+
const solidOIDCIssuerIRI = "http://www.w3.org/ns/solid/terms#oidcIssuer"
31+
2432
func HandleDerivedResourceRequest(w http.ResponseWriter, r *http.Request) {
2533
if r.Method != http.MethodPost {
2634
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -179,6 +187,12 @@ func deriveOwnerWebID(sourceURL string) string {
179187
return ""
180188
}
181189

190+
candidates := candidateOwnerWebIDs(parsed)
191+
if resolved := resolveOwnerFromCandidates(candidates); resolved != "" {
192+
return resolved
193+
}
194+
195+
// Fallback heuristic when remote probing is unavailable.
182196
if strings.Contains(parsed.Path, "/profile/card") {
183197
base := strings.TrimSuffix(parsed.Path, "#me")
184198
if !strings.HasSuffix(base, "/card") && !strings.HasSuffix(base, "/profile/card") {
@@ -194,3 +208,117 @@ func deriveOwnerWebID(sourceURL string) string {
194208

195209
return fmt.Sprintf("%s://%s/%s/profile/card#me", parsed.Scheme, parsed.Host, segments[0])
196210
}
211+
212+
func candidateOwnerWebIDs(parsed *url.URL) []string {
213+
base := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
214+
candidates := []string{
215+
base + "/profile/card#me",
216+
}
217+
218+
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
219+
if len(segments) == 1 && segments[0] == "" {
220+
return uniqueStringList(candidates)
221+
}
222+
223+
// Treat the last segment as a file path component when it looks file-like.
224+
if !strings.HasSuffix(parsed.Path, "/") && len(segments) > 0 && strings.Contains(segments[len(segments)-1], ".") {
225+
segments = segments[:len(segments)-1]
226+
}
227+
228+
for i := 1; i <= len(segments); i++ {
229+
prefix := strings.Join(segments[:i], "/")
230+
candidates = append(candidates, fmt.Sprintf("%s/%s/profile/card#me", base, prefix))
231+
}
232+
233+
return uniqueStringList(candidates)
234+
}
235+
236+
func resolveOwnerFromCandidates(candidates []string) string {
237+
if len(candidates) == 0 {
238+
return ""
239+
}
240+
241+
type probeResult struct {
242+
index int
243+
ok bool
244+
}
245+
246+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
247+
defer cancel()
248+
249+
results := make(chan probeResult, len(candidates))
250+
var wg sync.WaitGroup
251+
for i, candidate := range candidates {
252+
wg.Add(1)
253+
go func(idx int, webID string) {
254+
defer wg.Done()
255+
results <- probeResult{index: idx, ok: probeProfileCard(ctx, webID)}
256+
}(i, candidate)
257+
}
258+
259+
wg.Wait()
260+
close(results)
261+
262+
// Prefer the most specific successful candidate (highest index).
263+
bestIndex := -1
264+
for result := range results {
265+
if result.ok && result.index > bestIndex {
266+
bestIndex = result.index
267+
}
268+
}
269+
if bestIndex < 0 {
270+
return ""
271+
}
272+
return candidates[bestIndex]
273+
}
274+
275+
func probeProfileCard(ctx context.Context, webID string) bool {
276+
parsed, err := url.Parse(strings.TrimSpace(webID))
277+
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
278+
return false
279+
}
280+
parsed.Fragment = ""
281+
282+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
283+
if err != nil {
284+
return false
285+
}
286+
req.Header.Set("Accept", "text/turtle,application/ld+json,application/json,*/*")
287+
288+
resp, err := profileProbeClient.Do(req)
289+
if err != nil {
290+
return false
291+
}
292+
defer resp.Body.Close()
293+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
294+
if err != nil {
295+
return false
296+
}
297+
298+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
299+
return false
300+
}
301+
return hasOIDCIssuerPredicate(body)
302+
}
303+
304+
func hasOIDCIssuerPredicate(body []byte) bool {
305+
if len(body) == 0 {
306+
return false
307+
}
308+
payload := strings.ToLower(string(body))
309+
return strings.Contains(payload, strings.ToLower(solidOIDCIssuerIRI)) ||
310+
strings.Contains(payload, "solid:oidcissuer")
311+
}
312+
313+
func uniqueStringList(values []string) []string {
314+
seen := make(map[string]struct{}, len(values))
315+
out := make([]string, 0, len(values))
316+
for _, value := range values {
317+
if _, ok := seen[value]; ok {
318+
continue
319+
}
320+
seen[value] = struct{}{}
321+
out = append(out, value)
322+
}
323+
return out
324+
}

containers/ingress-uma/auth/derived_resources_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package auth
22

33
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
48
"testing"
9+
"time"
510
)
611

712
func TestDerivedResourceIDs_ServiceLocation(t *testing.T) {
@@ -52,6 +57,61 @@ func TestDeriveOwnerWebID(t *testing.T) {
5257
}
5358
}
5459

60+
func TestDeriveOwnerWebID_ProbeNestedCandidatePaths(t *testing.T) {
61+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62+
if r.URL.Path == "/css1/alice/profile/card" {
63+
w.WriteHeader(http.StatusOK)
64+
_, _ = w.Write([]byte(`
65+
@prefix solid: <http://www.w3.org/ns/solid/terms#> .
66+
<> solid:oidcIssuer <https://idp.example> .
67+
`))
68+
return
69+
}
70+
http.NotFound(w, r)
71+
}))
72+
defer server.Close()
73+
74+
sourceURL := server.URL + "/css1/alice/private/data/text.txt"
75+
expected := server.URL + "/css1/alice/profile/card#me"
76+
77+
if got := deriveOwnerWebID(sourceURL); got != expected {
78+
t.Fatalf("expected %q, got %q", expected, got)
79+
}
80+
}
81+
82+
func TestCandidateOwnerWebIDs_FilePath(t *testing.T) {
83+
parsed, err := url.Parse("http://example.org/css1/alice/private/data/text.txt")
84+
if err != nil {
85+
t.Fatalf("failed to parse URL: %v", err)
86+
}
87+
88+
got := candidateOwnerWebIDs(parsed)
89+
expected := []string{
90+
"http://example.org/profile/card#me",
91+
"http://example.org/css1/profile/card#me",
92+
"http://example.org/css1/alice/profile/card#me",
93+
"http://example.org/css1/alice/private/profile/card#me",
94+
"http://example.org/css1/alice/private/data/profile/card#me",
95+
}
96+
97+
assertStringSetEqual(t, got, expected)
98+
}
99+
100+
func TestProbeProfileCard_RequiresOIDCIssuerPredicate(t *testing.T) {
101+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+
w.WriteHeader(http.StatusOK)
103+
_, _ = w.Write([]byte("@prefix foaf: <http://xmlns.com/foaf/0.1/> ."))
104+
}))
105+
defer server.Close()
106+
107+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
108+
defer cancel()
109+
110+
if probeProfileCard(ctx, server.URL+"/profile/card#me") {
111+
t.Fatal("expected probe to fail when solid:oidcIssuer is missing")
112+
}
113+
}
114+
55115
func assertStringSetEqual(t *testing.T, got []string, expected []string) {
56116
t.Helper()
57117
if len(got) != len(expected) {

0 commit comments

Comments
 (0)