mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 00:39:13 +00:00
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:
parent
a8c4114d6d
commit
1c23b5df57
8 changed files with 147 additions and 48 deletions
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue