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>
This commit is contained in:
soup 2025-04-13 17:45:30 +02:00 committed by GitHub
parent a8c4114d6d
commit 1c23b5df57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 147 additions and 48 deletions

View file

@ -39,6 +39,17 @@ type OIDCHandler struct {
cookieStore *sessions.CookieStore 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) { func NewOIDCHandler(cfg *domain.Config, log zerolog.Logger) (*OIDCHandler, error) {
log.Debug(). log.Debug().
Bool("oidc_enabled", cfg.OIDCEnabled). 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) 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") h.log.Debug().Msg("handling OIDC callback")
// get state from session // get state from session
session, err := h.cookieStore.Get(r, "oidc_state") session, err := h.cookieStore.Get(r, "oidc_state")
if err != nil { if err != nil {
h.log.Error().Err(err).Msg("state session not found") 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) expectedState, ok := session.Values["state"].(string)
if !ok { if !ok {
h.log.Error().Msg("state not found in session") 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 { 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") 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 // 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") code := r.URL.Query().Get("code")
if code == "" { if code == "" {
h.log.Error().Msg("authorization code is missing from callback request") 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) oauth2Token, err := h.oauthConfig.Exchange(r.Context(), code)
if err != nil { if err != nil {
h.log.Error().Err(err).Msg("failed to exchange token") 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) rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok { if !ok {
h.log.Error().Msg("no id_token found in oauth2 token") 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) idToken, err := h.verifier.Verify(r.Context(), rawIDToken)
if err != nil { if err != nil {
h.log.Error().Err(err).Msg("failed to verify ID Token") 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 { var claims OIDCClaims
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"`
}
if err := idToken.Claims(&claims); err != nil { if err := idToken.Claims(&claims); err != nil {
h.log.Error().Err(err).Msg("failed to parse claims") 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 // Try different claims in order of preference for username
// This is solely used for frontend display // This is solely used for frontend display
username := claims.Username if claims.Username == "" {
if username == "" {
if claims.Nickname != "" { if claims.Nickname != "" {
username = claims.Nickname claims.Username = claims.Nickname
} else if claims.Name != "" { } else if claims.Name != "" {
username = claims.Name claims.Username = claims.Name
} else if claims.Email != "" { } else if claims.Email != "" {
username = claims.Email claims.Username = claims.Email
} else if claims.Sub != "" { } else if claims.Sub != "" {
username = claims.Sub claims.Username = claims.Sub
} else { } 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 { func generateRandomState() string {
@ -288,6 +291,7 @@ type GetConfigResponse struct {
AuthorizationURL string `json:"authorizationUrl"` AuthorizationURL string `json:"authorizationUrl"`
State string `json:"state"` State string `json:"state"`
DisableBuiltInLogin bool `json:"disableBuiltInLogin"` DisableBuiltInLogin bool `json:"disableBuiltInLogin"`
IssuerURL string `json:"issuerUrl"`
} }
func (h *OIDCHandler) GetConfigResponse() GetConfigResponse { func (h *OIDCHandler) GetConfigResponse() GetConfigResponse {
@ -295,19 +299,21 @@ func (h *OIDCHandler) GetConfigResponse() GetConfigResponse {
return GetConfigResponse{ return GetConfigResponse{
Enabled: false, Enabled: false,
DisableBuiltInLogin: false, DisableBuiltInLogin: false,
IssuerURL: "",
} }
} }
state := generateRandomState() state := generateRandomState()
authURL := h.oauthConfig.AuthCodeURL(state) 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{ return GetConfigResponse{
Enabled: h.config.Enabled, Enabled: h.config.Enabled,
AuthorizationURL: authURL, AuthorizationURL: authURL,
State: state, State: state,
DisableBuiltInLogin: h.config.DisableBuiltInLogin, DisableBuiltInLogin: h.config.DisableBuiltInLogin,
IssuerURL: h.config.Issuer,
} }
} }

View file

@ -217,6 +217,10 @@ func (h authHandler) validate(w http.ResponseWriter, r *http.Request) {
"auth_method": session.Values["auth_method"], "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) h.encoder.StatusResponse(w, http.StatusOK, response)
return return
} }
@ -279,7 +283,7 @@ func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
return return
} }
username, err := h.oidcHandler.HandleCallback(w, r) claims, err := h.oidcHandler.HandleCallback(w, r)
if err != nil { if err != nil {
h.encoder.StatusError(w, http.StatusUnauthorized, errors.Wrap(err, "OIDC authentication failed")) h.encoder.StatusError(w, http.StatusUnauthorized, errors.Wrap(err, "OIDC authentication failed"))
return return
@ -288,7 +292,7 @@ func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
// Create new session // Create new session
session, err := h.cookieStore.Get(r, "user_session") session, err := h.cookieStore.Get(r, "user_session")
if err != nil { 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")) h.encoder.StatusError(w, http.StatusInternalServerError, errors.New("could not create cookies"))
return return
} }
@ -296,8 +300,12 @@ func (h authHandler) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
// Set user as authenticated // Set user as authenticated
session.Values["authenticated"] = true session.Values["authenticated"] = true
session.Values["created"] = time.Now().Unix() session.Values["created"] = time.Now().Unix()
session.Values["username"] = username session.Values["username"] = claims.Username
session.Values["auth_method"] = "oidc" 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 // Set cookie options
session.Options.HttpOnly = true session.Options.HttpOnly = true

View file

@ -13,6 +13,7 @@ type Primitive = string | number | boolean | symbol | undefined;
type ValidateResponse = { type ValidateResponse = {
username?: AuthInfo['username']; username?: AuthInfo['username'];
auth_method?: AuthInfo['authMethod']; auth_method?: AuthInfo['authMethod'];
profile_picture?: AuthInfo['profilePicture'];
} }
interface HttpConfig { interface HttpConfig {
@ -262,10 +263,10 @@ export const APIClient = {
{ body: req }), { body: req }),
getOIDCConfig: async () => { getOIDCConfig: async () => {
try { 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) { } catch (error: unknown) {
if (error instanceof Error && error.message?.includes('404')) { 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; throw error;
} }

View file

@ -19,7 +19,6 @@ import { AuthContext, SettingsContext } from "@utils/Context";
export const RightNav = (props: RightNavProps) => { export const RightNav = (props: RightNavProps) => {
const [settings, setSettings] = SettingsContext.use(); const [settings, setSettings] = SettingsContext.use();
const auth = AuthContext.get(); const auth = AuthContext.get();
const toggleTheme = () => { const toggleTheme = () => {
@ -35,7 +34,7 @@ export const RightNav = (props: RightNavProps) => {
<div className="mt-1 items-center"> <div className="mt-1 items-center">
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="p-1 rounded-full focus:outline-hidden focus:none transition duration-100 ease-out transform hover:bg-gray-200 dark:hover:bg-gray-800 hover:scale-100" className="p-1 rounded-full hover:cursor-pointer focus:outline-hidden focus:none transition duration-100 ease-out transform hover:bg-gray-200 dark:hover:bg-gray-800 hover:scale-100"
title={settings.darkTheme ? "Switch to light mode (currently dark mode)" : "Switch to dark mode (currently light mode)"} title={settings.darkTheme ? "Switch to light mode (currently dark mode)" : "Switch to dark mode (currently light mode)"}
> >
{settings.darkTheme ? ( {settings.darkTheme ? (
@ -56,25 +55,34 @@ export const RightNav = (props: RightNavProps) => {
"transition duration-200" "transition duration-200"
)} )}
> >
<span className="hidden text-sm font-medium sm:block"> <span className="hidden hover:cursor-pointer text-sm font-medium sm:flex items-center">
<span className="sr-only"> <span className="sr-only">
Open user menu for{" "} Open user menu for{" "}
</span> </span>
<span className="flex items-center"> <span className="flex items-center">{auth.username}</span>
{auth.username} {auth.authMethod === 'oidc' ? (
{auth.authMethod === 'oidc' ? ( auth.profilePicture ? (
<div className="relative flex-shrink-0 ml-2 w-6 h-6 overflow-hidden rounded-full ring-1 ring-white dark:ring-gray-700">
<img
src={auth.profilePicture}
alt={`${auth.username}'s profile`}
className="object-cover w-full h-full transition-opacity duration-200"
onError={() => auth.profilePicture = undefined}
/>
</div>
) : (
<FontAwesomeIcon <FontAwesomeIcon
icon={faOpenid} icon={faOpenid}
className="inline ml-1 h-4 w-4 text-gray-500 dark:text-gray-500" className="inline ml-1 h-4 w-4 text-gray-500 dark:text-gray-500"
aria-hidden="true" aria-hidden="true"
/> />
) : ( )
<UserIcon ) : (
className="inline ml-1 h-5 w-5" <UserIcon
aria-hidden="true" className="inline ml-1 h-5 w-5"
/> aria-hidden="true"
)} />
</span> )}
</span> </span>
</MenuButton> </MenuButton>
<Transition <Transition

View file

@ -304,10 +304,19 @@ export const AuthRoute = createRoute({
if (!AuthContext.get().isLoggedIn) { if (!AuthContext.get().isLoggedIn) {
try { try {
const response = await APIClient.auth.validate(); const response = await APIClient.auth.validate();
// Also get OIDC config if needed
let issuerUrl;
if (response.auth_method === 'oidc') {
const oidcConfig = await APIClient.auth.getOIDCConfig();
issuerUrl = oidcConfig.issuerUrl;
}
AuthContext.set({ AuthContext.set({
isLoggedIn: true, isLoggedIn: true,
username: response.username || 'unknown', username: response.username || 'unknown',
authMethod: response.auth_method authMethod: response.auth_method,
profilePicture: response.profile_picture,
issuerUrl: issuerUrl
}); });
} catch (error) { } catch (error) {
console.debug("Authentication validation failed:", error); console.debug("Authentication validation failed:", error);

View file

@ -30,6 +30,7 @@ type LoginFormFields = {
type ValidateResponse = { type ValidateResponse = {
username?: AuthInfo['username']; username?: AuthInfo['username'];
auth_method?: AuthInfo['authMethod']; auth_method?: AuthInfo['authMethod'];
profile_picture?: AuthInfo['profilePicture'];
} }
export const Login = () => { export const Login = () => {
@ -85,7 +86,8 @@ export const Login = () => {
setAuth({ setAuth({
isLoggedIn: true, isLoggedIn: true,
username: response.username || 'unknown', username: response.username || 'unknown',
authMethod: response.auth_method || (oidcConfig?.enabled ? 'oidc' : 'password') authMethod: response.auth_method || (oidcConfig?.enabled ? 'oidc' : 'password'),
profilePicture: response.profile_picture,
}); });
router.invalidate(); router.invalidate();
}).catch((error) => { }).catch((error) => {

View file

@ -160,6 +160,21 @@ function Credentials() {
} }
function OIDCAccount() { function OIDCAccount() {
const auth = AuthContext.get();
// Helper function to format the issuer URL for display
const getFormattedIssuerName = () => {
if (!auth.issuerUrl) return "your identity provider";
try {
const url = new URL(auth.issuerUrl);
// Return domain name without 'www.'
return url.hostname.replace(/^www\./i, '');
} catch {
return "your identity provider";
}
};
return ( return (
<Section <Section
titleElement={ titleElement={
@ -172,6 +187,52 @@ function OIDCAccount() {
description="Your account credentials are managed by your OpenID Connect provider. To change your username, please visit your provider's settings page and log in again." description="Your account credentials are managed by your OpenID Connect provider. To change your username, please visit your provider's settings page and log in again."
noLeftPadding noLeftPadding
> >
<div className="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 transition duration-150">
<div className="flex flex-col sm:flex-row items-center">
<div className="flex-shrink-0 relative">
{auth.profilePicture ? (
<img
src={auth.profilePicture}
alt={`${auth.username}'s profile picture`}
className="h-16 w-16 sm:h-20 sm:w-20 rounded-full object-cover border-1 border-gray-200 dark:border-gray-700 transition duration-200"
onError={() => auth.profilePicture = undefined}
/>
) : (
<div className="h-16 w-16 sm:h-20 sm:w-20 rounded-full flex items-center justify-center bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 transition duration-200">
<FontAwesomeIcon
icon={faOpenid}
className="h-16 w-16 text-gray-500 dark:text-gray-400"
aria-hidden="true"
/>
</div>
)}
</div>
<div className="mt-4 sm:mt-0 sm:ml-6 text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">
{auth.username}
</h3>
<div className="mt-1 flex items-center text-sm text-gray-500 dark:text-gray-400">
<FontAwesomeIcon icon={faOpenid} className="mr-1.5 h-4 w-4 flex-shrink-0" />
<p>Authenticated via OpenID Connect</p>
</div>
{auth.issuerUrl && (
<div className="mt-2">
<a
href={auth.issuerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded-md text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 border border-blue-200 dark:border-blue-800 transition-colors duration-150"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{getFormattedIssuerName()}
</a>
</div>
)}
</div>
</div>
</div>
</Section> </Section>
); );
} }

View file

@ -24,13 +24,17 @@ export interface AuthInfo {
username: string; username: string;
isLoggedIn: boolean; isLoggedIn: boolean;
authMethod?: 'password' | 'oidc'; authMethod?: 'password' | 'oidc';
profilePicture?: string;
issuerUrl?: string;
} }
// Default values // Default values
const AuthContextDefaults: AuthInfo = { const AuthContextDefaults: AuthInfo = {
username: "", username: "",
isLoggedIn: false, isLoggedIn: false,
authMethod: undefined authMethod: undefined,
profilePicture: undefined,
issuerUrl: undefined
}; };
const SettingsContextDefaults: SettingsType = { const SettingsContextDefaults: SettingsType = {