Skip to content

Commit 9047b3d

Browse files
committed
feat(auth): proxy SSO profile photos for OAuth users
Fetch IdP avatars during OAuth callback with the access token, serve them via GET /api/v4/me/avatar, and show photos or initials in the UI header and profile panels (fixes homer-app#593).
1 parent a00805c commit 9047b3d

17 files changed

Lines changed: 396 additions & 14 deletions

docs/AUTH_LDAP_AND_OAUTH.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,17 @@ Environment variables:
350350

351351
Full sample: **`examples/homer-coordinator-oauth2-azure.sample.json`**.
352352

353+
### SSO profile photos (Microsoft Entra / OIDC)
354+
355+
The UI must not call IdP photo URLs (for example `https://graph.microsoft.com/v1.0/me/photo/$value`) directly — the browser has no access token. Homer 11 instead:
356+
357+
1. On OAuth callback, the coordinator fetches the photo with the IdP **access token** and caches it in memory (24h TTL per username).
358+
2. **`GET /api/v4/me`** returns **`avatar: "/me/avatar"`** when a cached photo exists.
359+
3. **`GET /api/v4/me/avatar`** (JWT required) streams the image bytes.
360+
4. The bundled UI loads the photo via authenticated `fetch` and shows **initials** when no photo is available.
361+
362+
For Azure, include **`User.Read`** in **`scopes`** (see sample JSON) so Graph can return the profile photo.
363+
353364
**`auto_redirect`** alone only redirects the browser to the IdP on page load; it does not disable password login. Use **`disable_password_login`** when password login must be blocked.
354365

355366
### Pre-provisioned OAuth users (`skip_auto_provision`)

examples/homer-coordinator-oauth2-azure.sample.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"token_url": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
1616
"redirect_url": "https://homer.example.com/api/v4/auth/oauth2/azure/callback",
1717
"profile_url": "https://graph.microsoft.com/oidc/userinfo",
18-
"scopes": ["openid", "email", "profile"],
18+
"scopes": ["openid", "email", "profile", "User.Read"],
1919
"use_pkce": false,
2020
"callback_url": "https://homer.example.com/",
2121
"auto_redirect": true,

src/coordinator/coordinator.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ func (c *Coordinator) setupRoutes() {
367367
{
368368
protectedV4.DELETE("/auth/sessions/:sessionId", authHandler.V4DeleteSession)
369369
protectedV4.GET("/me", authHandler.V4GetMe)
370+
protectedV4.GET("/me/avatar", authHandler.V4GetMeAvatar)
370371
protectedV4.PATCH("/me", authHandler.V4PatchMe)
371372
protectedV4.GET("/me/settings", userSettingsHandler.V4UserSettingsList)
372373
protectedV4.PUT("/me/settings/:category", userSettingsHandler.V4UserSettingsUpsert)

src/coordinator/handlers/auth.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type AuthHandler struct {
3232
ldapAuth *services.LDAPAuthService
3333
sessionStore *SessionStore
3434
oneTimeStore *OneTimeTokenStore
35+
avatarStore *AvatarStore
3536
providers []OAuthProvider
3637
oauthState *OAuthStateStore
3738

@@ -85,6 +86,7 @@ func NewAuthHandlerWithUserService(
8586
ldapAuth: ldapAuth,
8687
sessionStore: NewSessionStore(),
8788
oneTimeStore: NewOneTimeTokenStore(),
89+
avatarStore: NewAvatarStore(24 * time.Hour),
8890
oauthState: NewOAuthStateStore(),
8991
providers: providers,
9092
authTokenSvc: authTokenSvc,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (C) 2025 Homer Server Contributors
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
package handlers
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"strings"
13+
"time"
14+
15+
"github.com/labstack/echo/v4"
16+
)
17+
18+
const maxAvatarBytes = 2 << 20 // 2 MiB
19+
20+
// V4GetMeAvatar serves the cached SSO profile photo for the authenticated user.
21+
// GET /api/v4/me/avatar
22+
func (h *AuthHandler) V4GetMeAvatar(c echo.Context) error {
23+
claims, err := h.jwtClaimsFromContext(c)
24+
if err != nil {
25+
return v4ContextError(c, err)
26+
}
27+
if h.avatarStore == nil {
28+
return c.NoContent(http.StatusNotFound)
29+
}
30+
data, contentType, ok := h.avatarStore.Get(claims.Username)
31+
if !ok {
32+
return c.NoContent(http.StatusNotFound)
33+
}
34+
return c.Blob(http.StatusOK, contentType, data)
35+
}
36+
37+
func oauthAvatarURLs(profile map[string]interface{}, provider *OAuthProvider) []string {
38+
seen := make(map[string]struct{})
39+
var urls []string
40+
add := func(u string) {
41+
u = strings.TrimSpace(u)
42+
if u == "" {
43+
return
44+
}
45+
if _, ok := seen[u]; ok {
46+
return
47+
}
48+
seen[u] = struct{}{}
49+
urls = append(urls, u)
50+
}
51+
52+
add(oauthStringClaim(profile, "picture"))
53+
add(oauthStringClaim(profile, "avatar"))
54+
add(oauthStringClaim(profile, "photo"))
55+
56+
if provider != nil {
57+
profileURL := strings.ToLower(provider.ProfileURL)
58+
name := strings.ToLower(provider.Name)
59+
if strings.Contains(profileURL, "graph.microsoft.com") || name == "azure" || name == "microsoft" {
60+
add("https://graph.microsoft.com/v1.0/me/photo/$value")
61+
}
62+
}
63+
return urls
64+
}
65+
66+
func fetchOAuthProfilePhoto(ctx context.Context, client *http.Client, profile map[string]interface{}, provider *OAuthProvider) ([]byte, string, error) {
67+
if client == nil {
68+
return nil, "", fmt.Errorf("http client is nil")
69+
}
70+
var lastErr error
71+
for _, u := range oauthAvatarURLs(profile, provider) {
72+
data, ctype, err := fetchAuthenticatedImage(ctx, client, u)
73+
if err != nil {
74+
lastErr = err
75+
continue
76+
}
77+
if len(data) > 0 {
78+
return data, ctype, nil
79+
}
80+
}
81+
if lastErr != nil {
82+
return nil, "", lastErr
83+
}
84+
return nil, "", fmt.Errorf("no profile photo available")
85+
}
86+
87+
func fetchAuthenticatedImage(ctx context.Context, client *http.Client, rawURL string) ([]byte, string, error) {
88+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
89+
if err != nil {
90+
return nil, "", err
91+
}
92+
resp, err := client.Do(req)
93+
if err != nil {
94+
return nil, "", err
95+
}
96+
defer resp.Body.Close()
97+
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent {
98+
return nil, "", fmt.Errorf("photo HTTP %d", resp.StatusCode)
99+
}
100+
if resp.StatusCode != http.StatusOK {
101+
return nil, "", fmt.Errorf("photo HTTP %d", resp.StatusCode)
102+
}
103+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAvatarBytes))
104+
if err != nil {
105+
return nil, "", err
106+
}
107+
ctype := strings.TrimSpace(resp.Header.Get("Content-Type"))
108+
if ctype == "" || !strings.HasPrefix(ctype, "image/") {
109+
ctype = "image/jpeg"
110+
}
111+
return body, ctype, nil
112+
}
113+
114+
func cacheOAuthAvatar(h *AuthHandler, username string, profile map[string]interface{}, client *http.Client, provider *OAuthProvider) {
115+
if h == nil || h.avatarStore == nil || username == "" || client == nil {
116+
return
117+
}
118+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
119+
defer cancel()
120+
data, ctype, err := fetchOAuthProfilePhoto(ctx, client, profile, provider)
121+
if err != nil || len(data) == 0 {
122+
return
123+
}
124+
h.avatarStore.Put(username, data, ctype)
125+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (C) 2025 Homer Server Contributors
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
package handlers
6+
7+
import (
8+
"sync"
9+
"time"
10+
)
11+
12+
type avatarEntry struct {
13+
data []byte
14+
contentType string
15+
expiresAt time.Time
16+
}
17+
18+
// AvatarStore caches SSO profile photos keyed by username (in-memory, refreshed on OAuth login).
19+
type AvatarStore struct {
20+
mu sync.RWMutex
21+
items map[string]avatarEntry
22+
ttl time.Duration
23+
}
24+
25+
func NewAvatarStore(ttl time.Duration) *AvatarStore {
26+
if ttl <= 0 {
27+
ttl = 24 * time.Hour
28+
}
29+
return &AvatarStore{
30+
items: make(map[string]avatarEntry),
31+
ttl: ttl,
32+
}
33+
}
34+
35+
func (s *AvatarStore) Put(username string, data []byte, contentType string) {
36+
if s == nil || username == "" || len(data) == 0 {
37+
return
38+
}
39+
if contentType == "" {
40+
contentType = "image/jpeg"
41+
}
42+
s.mu.Lock()
43+
defer s.mu.Unlock()
44+
s.items[username] = avatarEntry{
45+
data: append([]byte(nil), data...),
46+
contentType: contentType,
47+
expiresAt: time.Now().Add(s.ttl),
48+
}
49+
}
50+
51+
func (s *AvatarStore) Get(username string) (data []byte, contentType string, ok bool) {
52+
if s == nil || username == "" {
53+
return nil, "", false
54+
}
55+
s.mu.RLock()
56+
entry, found := s.items[username]
57+
s.mu.RUnlock()
58+
if !found {
59+
return nil, "", false
60+
}
61+
if time.Now().After(entry.expiresAt) {
62+
s.mu.Lock()
63+
delete(s.items, username)
64+
s.mu.Unlock()
65+
return nil, "", false
66+
}
67+
return append([]byte(nil), entry.data...), entry.contentType, true
68+
}
69+
70+
func (s *AvatarStore) Has(username string) bool {
71+
_, _, ok := s.Get(username)
72+
return ok
73+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package handlers
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestOauthAvatarURLsMicrosoft(t *testing.T) {
9+
prof := map[string]interface{}{
10+
"picture": "https://graph.microsoft.com/v1.0/me/photo/$value",
11+
}
12+
p := &OAuthProvider{
13+
Name: "azure",
14+
ProfileURL: "https://graph.microsoft.com/oidc/userinfo",
15+
}
16+
urls := oauthAvatarURLs(prof, p)
17+
if len(urls) < 1 || urls[0] != "https://graph.microsoft.com/v1.0/me/photo/$value" {
18+
t.Fatalf("urls: %#v", urls)
19+
}
20+
}
21+
22+
func TestAvatarStorePutGet(t *testing.T) {
23+
s := NewAvatarStore(time.Minute)
24+
s.Put("alice", []byte{1, 2, 3}, "image/png")
25+
data, ctype, ok := s.Get("alice")
26+
if !ok || ctype != "image/png" || len(data) != 3 {
27+
t.Fatalf("get: ok=%v ctype=%s len=%d", ok, ctype, len(data))
28+
}
29+
if !s.Has("alice") {
30+
t.Fatal("expected Has true")
31+
}
32+
}

src/coordinator/handlers/auth_oauth_code.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ func (h *AuthHandler) V4OAuth2Callback(c echo.Context) error {
161161
return h.oauthRedirectWithError(c, provider, err.Error())
162162
}
163163

164+
cacheOAuthAvatar(h, u.Username, profile, client, provider)
165+
164166
isAdmin := u.IsAdmin || staffAdmin
165167

166168
one := newSessionID()

src/coordinator/handlers/auth_v4.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,5 +380,10 @@ func (h *AuthHandler) buildUserProfileV4(c echo.Context, claims *JWTClaims) User
380380
}
381381
}
382382

383+
if h.avatarStore != nil && h.avatarStore.Has(claims.Username) {
384+
profile.Avatar = "/me/avatar"
385+
profile.ExternalAuth = true
386+
}
387+
383388
return profile
384389
}

src/ui/src/App.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
canServerReset,
2626
} from './settings/permissions'
2727
import DashboardHeader from './dashboard/DashboardHeader'
28-
import { handleUnauthorized } from './api'
28+
import { fetchMeAvatarObjectUrl, handleUnauthorized } from './api'
2929
import { ThemeProvider } from "@/components/theme/theme-provider"
3030
import { WindowDock } from "@/components/ui/window-dock"
3131
import { useConfirm } from "@/components/ui/confirm-dialog"
@@ -86,6 +86,7 @@ function App() {
8686
const [settingsOpen, setSettingsOpen] = useState(false)
8787
const [activeSection, setActiveSection] = useState('about')
8888
const [me, setMe] = useState<any>(null)
89+
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
8990
const [users, setUsers] = useState<any[]>([])
9091
const [loadingMe, setLoadingMe] = useState(false)
9192
const [loadingUsers, setLoadingUsers] = useState(false)
@@ -202,7 +203,15 @@ function App() {
202203
window.location.href = `${apiBase}/auth/oauth2/${encodeURIComponent(provider)}/redirect`
203204
}
204205

206+
const revokeAvatarUrl = (url: string | null) => {
207+
if (url) {
208+
URL.revokeObjectURL(url)
209+
}
210+
}
211+
205212
const logout = () => {
213+
revokeAvatarUrl(avatarUrl)
214+
setAvatarUrl(null)
206215
setToken('')
207216
setMe(null)
208217
setUsers([])
@@ -249,7 +258,18 @@ function App() {
249258
return
250259
}
251260
const payload = await res.json()
252-
setMe(payload?.data || null)
261+
const data = payload?.data || null
262+
setMe(data)
263+
revokeAvatarUrl(avatarUrl)
264+
let nextAvatar: string | null = null
265+
if (data?.avatar) {
266+
try {
267+
nextAvatar = await fetchMeAvatarObjectUrl()
268+
} catch {
269+
// Avatar is optional; keep initials fallback in the UI.
270+
}
271+
}
272+
setAvatarUrl(nextAvatar)
253273
} catch (err) {
254274
toast.error(`Failed to load profile: ${(err as Error).message}`)
255275
} finally {
@@ -501,9 +521,17 @@ function App() {
501521

502522
switch (activeSection) {
503523
case 'about':
504-
return <AboutPanel me={me} loading={loadingMe} onRefresh={loadMe} />
524+
return <AboutPanel me={me} avatarUrl={avatarUrl} loading={loadingMe} onRefresh={loadMe} />
505525
case 'profile':
506-
return <ProfilePanel me={me} loading={loadingMe} onRefresh={loadMe} readOnly={readOnly} />
526+
return (
527+
<ProfilePanel
528+
me={me}
529+
avatarUrl={avatarUrl}
530+
loading={loadingMe}
531+
onRefresh={loadMe}
532+
readOnly={readOnly}
533+
/>
534+
)
507535
case 'users':
508536
return (
509537
<UsersPanel
@@ -579,6 +607,7 @@ function App() {
579607
apiBase={apiBase}
580608
token={token}
581609
me={me}
610+
userAvatarUrl={avatarUrl}
582611
onOpenSettings={openSettings}
583612
onOpenDashboard={openDashboard}
584613
onLogout={logout}
@@ -594,7 +623,8 @@ function App() {
594623
calendarPreset={null}
595624
timeZone=""
596625
onTimeZoneChange={() => { }}
597-
userLabel={me?.username || 'User'}
626+
userLabel={me?.display_name || me?.username || 'User'}
627+
userAvatarUrl={avatarUrl}
598628
onOpenSettings={openSettings}
599629
onOpenDashboard={openDashboard}
600630
onLogout={logout}

0 commit comments

Comments
 (0)