From 1c23b5df5780f4e02f35c2ee7d974a0b8890175f Mon Sep 17 00:00:00 2001 From: soup Date: Sun, 13 Apr 2025 17:45:30 +0200 Subject: [PATCH] feat(oidc): show profile pic if present (#2006) * feat(oidc): fetch profile picture * small imprvements * Add link to provider * fix(rightnav): add cursor-pointer on hover * adjust picture border and layout in RightNav and Account components * cleanup * oidc claims struct * check if profile_picture exists * simplify profile picture error handling * adhere to autobrr log style * fix: remove unused imports --------- Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com> --- internal/auth/oidc.go | 60 +++++++++++++------------ internal/http/auth.go | 14 ++++-- web/src/api/APIClient.ts | 5 ++- web/src/components/header/RightNav.tsx | 34 ++++++++------ web/src/routes.tsx | 11 ++++- web/src/screens/auth/Login.tsx | 4 +- web/src/screens/settings/Account.tsx | 61 ++++++++++++++++++++++++++ web/src/utils/Context.ts | 6 ++- 8 files changed, 147 insertions(+), 48 deletions(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index e71babf..e2fd09b 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -39,6 +39,17 @@ type OIDCHandler struct { cookieStore *sessions.CookieStore } +// OIDCClaims represents the claims returned from the OIDC provider +type OIDCClaims struct { + Email string `json:"email"` + Username string `json:"preferred_username"` + Name string `json:"name"` + GivenName string `json:"given_name"` + Nickname string `json:"nickname"` + Sub string `json:"sub"` + Picture string `json:"picture"` +} + func NewOIDCHandler(cfg *domain.Config, log zerolog.Logger) (*OIDCHandler, error) { log.Debug(). Bool("oidc_enabled", cfg.OIDCEnabled). @@ -180,25 +191,25 @@ func (h *OIDCHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, authURL, http.StatusFound) } -func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) (string, error) { +func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) (*OIDCClaims, error) { h.log.Debug().Msg("handling OIDC callback") // get state from session session, err := h.cookieStore.Get(r, "oidc_state") if err != nil { h.log.Error().Err(err).Msg("state session not found") - return "", errors.New("state session not found") + return nil, errors.New("state session not found") } expectedState, ok := session.Values["state"].(string) if !ok { h.log.Error().Msg("state not found in session") - return "", errors.New("state not found in session") + return nil, errors.New("state not found in session") } if r.URL.Query().Get("state") != expectedState { h.log.Error().Str("expected", expectedState).Str("got", r.URL.Query().Get("state")).Msg("state did not match") - return "", errors.New("state did not match") + return nil, errors.New("state did not match") } // clear the state session after use @@ -210,60 +221,52 @@ func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) (st code := r.URL.Query().Get("code") if code == "" { h.log.Error().Msg("authorization code is missing from callback request") - return "", errors.New("authorization code is missing from callback request") + return nil, errors.New("authorization code is missing from callback request") } oauth2Token, err := h.oauthConfig.Exchange(r.Context(), code) if err != nil { h.log.Error().Err(err).Msg("failed to exchange token") - return "", errors.Wrap(err, "failed to exchange token") + return nil, errors.Wrap(err, "failed to exchange token") } rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { h.log.Error().Msg("no id_token found in oauth2 token") - return "", errors.New("no id_token found in oauth2 token") + return nil, errors.New("no id_token found in oauth2 token") } idToken, err := h.verifier.Verify(r.Context(), rawIDToken) if err != nil { h.log.Error().Err(err).Msg("failed to verify ID Token") - return "", errors.Wrap(err, "failed to verify ID Token") + return nil, errors.Wrap(err, "failed to verify ID Token") } - var claims struct { - Email string `json:"email"` - Username string `json:"preferred_username"` - Name string `json:"name"` - GivenName string `json:"given_name"` - Nickname string `json:"nickname"` - Sub string `json:"sub"` - } + var claims OIDCClaims if err := idToken.Claims(&claims); err != nil { h.log.Error().Err(err).Msg("failed to parse claims") - return "", errors.Wrap(err, "failed to parse claims") + return nil, errors.Wrap(err, "failed to parse claims") } // Try different claims in order of preference for username // This is solely used for frontend display - username := claims.Username - if username == "" { + if claims.Username == "" { if claims.Nickname != "" { - username = claims.Nickname + claims.Username = claims.Nickname } else if claims.Name != "" { - username = claims.Name + claims.Username = claims.Name } else if claims.Email != "" { - username = claims.Email + claims.Username = claims.Email } else if claims.Sub != "" { - username = claims.Sub + claims.Username = claims.Sub } else { - username = "oidc_user" + claims.Username = "oidc_user" } } - h.log.Debug().Str("username", username).Str("email", claims.Email).Str("nickname", claims.Nickname).Str("name", claims.Name).Str("sub", claims.Sub).Msg("successfully processed OIDC claims") + h.log.Debug().Str("username", claims.Username).Str("email", claims.Email).Str("nickname", claims.Nickname).Str("name", claims.Name).Str("sub", claims.Sub).Str("picture", claims.Picture).Msg("successfully processed OIDC claims") - return username, nil + return &claims, nil } func generateRandomState() string { @@ -288,6 +291,7 @@ type GetConfigResponse struct { AuthorizationURL string `json:"authorizationUrl"` State string `json:"state"` DisableBuiltInLogin bool `json:"disableBuiltInLogin"` + IssuerURL string `json:"issuerUrl"` } func (h *OIDCHandler) GetConfigResponse() GetConfigResponse { @@ -295,19 +299,21 @@ func (h *OIDCHandler) GetConfigResponse() GetConfigResponse { return GetConfigResponse{ Enabled: false, DisableBuiltInLogin: false, + IssuerURL: "", } } state := generateRandomState() authURL := h.oauthConfig.AuthCodeURL(state) - h.log.Debug().Bool("enabled", h.config.Enabled).Str("authorization_url", authURL).Str("state", state).Bool("disable_built_in_login", h.config.DisableBuiltInLogin).Msg("returning OIDC config response") + h.log.Debug().Bool("enabled", h.config.Enabled).Str("authorization_url", authURL).Str("state", state).Bool("disable_built_in_login", h.config.DisableBuiltInLogin).Str("issuer_url", h.config.Issuer).Msg("returning OIDC config response") return GetConfigResponse{ Enabled: h.config.Enabled, AuthorizationURL: authURL, State: state, DisableBuiltInLogin: h.config.DisableBuiltInLogin, + IssuerURL: h.config.Issuer, } } diff --git a/internal/http/auth.go b/internal/http/auth.go index d43267c..cd98763 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -217,6 +217,10 @@ func (h authHandler) validate(w http.ResponseWriter, r *http.Request) { "auth_method": session.Values["auth_method"], } + if profilePicture, ok := session.Values["profile_picture"].(string); ok && profilePicture != "" { + response["profile_picture"] = profilePicture + } + h.encoder.StatusResponse(w, http.StatusOK, response) return } @@ -279,7 +283,7 @@ func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) return } - username, err := h.oidcHandler.HandleCallback(w, r) + claims, err := h.oidcHandler.HandleCallback(w, r) if err != nil { h.encoder.StatusError(w, http.StatusUnauthorized, errors.Wrap(err, "OIDC authentication failed")) return @@ -288,7 +292,7 @@ func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) // Create new session session, err := h.cookieStore.Get(r, "user_session") if err != nil { - h.log.Error().Err(err).Msgf("Auth: Failed to create cookies with attempt username: [%s] ip: %s", username, r.RemoteAddr) + h.log.Error().Err(err).Msgf("Auth: Failed to create cookies with attempt username: [%s] ip: %s", claims.Username, r.RemoteAddr) h.encoder.StatusError(w, http.StatusInternalServerError, errors.New("could not create cookies")) return } @@ -296,8 +300,12 @@ func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) // Set user as authenticated session.Values["authenticated"] = true session.Values["created"] = time.Now().Unix() - session.Values["username"] = username + session.Values["username"] = claims.Username session.Values["auth_method"] = "oidc" + if claims.Picture != "" { + session.Values["profile_picture"] = claims.Picture + h.log.Debug().Str("profile_picture", claims.Picture).Msg("storing profile picture URL in session") + } // Set cookie options session.Options.HttpOnly = true diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 1ccd181..fb7ef4b 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -13,6 +13,7 @@ type Primitive = string | number | boolean | symbol | undefined; type ValidateResponse = { username?: AuthInfo['username']; auth_method?: AuthInfo['authMethod']; + profile_picture?: AuthInfo['profilePicture']; } interface HttpConfig { @@ -262,10 +263,10 @@ export const APIClient = { { body: req }), getOIDCConfig: async () => { try { - return await appClient.Get<{ enabled: boolean; authorizationUrl: string; state: string; disableBuiltInLogin: boolean }>("api/auth/oidc/config"); + return await appClient.Get<{ enabled: boolean; authorizationUrl: string; state: string; disableBuiltInLogin: boolean; issuerUrl: string }>("api/auth/oidc/config"); } catch (error: unknown) { if (error instanceof Error && error.message?.includes('404')) { - return { enabled: false, authorizationUrl: '', state: '', disableBuiltInLogin: false }; + return { enabled: false, authorizationUrl: '', state: '', disableBuiltInLogin: false, issuerUrl: '' }; } throw error; } diff --git a/web/src/components/header/RightNav.tsx b/web/src/components/header/RightNav.tsx index 3b31e7a..8cb3810 100644 --- a/web/src/components/header/RightNav.tsx +++ b/web/src/components/header/RightNav.tsx @@ -19,7 +19,6 @@ import { AuthContext, SettingsContext } from "@utils/Context"; export const RightNav = (props: RightNavProps) => { const [settings, setSettings] = SettingsContext.use(); - const auth = AuthContext.get(); const toggleTheme = () => { @@ -35,7 +34,7 @@ export const RightNav = (props: RightNavProps) => {