fix(auth): preserve backward-compatible oauth defaults
This commit is contained in:
@@ -1202,7 +1202,7 @@ func setDefaults() {
|
|||||||
viper.SetDefault("linuxdo_connect.redirect_url", "")
|
viper.SetDefault("linuxdo_connect.redirect_url", "")
|
||||||
viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback")
|
viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback")
|
||||||
viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post")
|
viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post")
|
||||||
viper.SetDefault("linuxdo_connect.use_pkce", true)
|
viper.SetDefault("linuxdo_connect.use_pkce", false)
|
||||||
viper.SetDefault("linuxdo_connect.userinfo_email_path", "")
|
viper.SetDefault("linuxdo_connect.userinfo_email_path", "")
|
||||||
viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
|
viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
|
||||||
viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
|
viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
|
||||||
@@ -1222,8 +1222,8 @@ func setDefaults() {
|
|||||||
viper.SetDefault("oidc_connect.redirect_url", "")
|
viper.SetDefault("oidc_connect.redirect_url", "")
|
||||||
viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback")
|
viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback")
|
||||||
viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post")
|
viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post")
|
||||||
viper.SetDefault("oidc_connect.use_pkce", true)
|
viper.SetDefault("oidc_connect.use_pkce", false)
|
||||||
viper.SetDefault("oidc_connect.validate_id_token", true)
|
viper.SetDefault("oidc_connect.validate_id_token", false)
|
||||||
viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256")
|
viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256")
|
||||||
viper.SetDefault("oidc_connect.clock_skew_seconds", 120)
|
viper.SetDefault("oidc_connect.clock_skew_seconds", 120)
|
||||||
viper.SetDefault("oidc_connect.require_email_verified", false)
|
viper.SetDefault("oidc_connect.require_email_verified", false)
|
||||||
@@ -1613,9 +1613,6 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
|
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
|
||||||
}
|
}
|
||||||
if c.LinuxDo.Enabled {
|
if c.LinuxDo.Enabled {
|
||||||
if !c.LinuxDo.UsePKCE {
|
|
||||||
return fmt.Errorf("linuxdo_connect.use_pkce must be true when linuxdo_connect.enabled=true")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.LinuxDo.ClientID) == "" {
|
if strings.TrimSpace(c.LinuxDo.ClientID) == "" {
|
||||||
return fmt.Errorf("linuxdo_connect.client_id is required when linuxdo_connect.enabled=true")
|
return fmt.Errorf("linuxdo_connect.client_id is required when linuxdo_connect.enabled=true")
|
||||||
}
|
}
|
||||||
@@ -1668,12 +1665,6 @@ func (c *Config) Validate() error {
|
|||||||
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
|
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
|
||||||
}
|
}
|
||||||
if c.OIDC.Enabled {
|
if c.OIDC.Enabled {
|
||||||
if !c.OIDC.UsePKCE {
|
|
||||||
return fmt.Errorf("oidc_connect.use_pkce must be true when oidc_connect.enabled=true")
|
|
||||||
}
|
|
||||||
if !c.OIDC.ValidateIDToken {
|
|
||||||
return fmt.Errorf("oidc_connect.validate_id_token must be true when oidc_connect.enabled=true")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.OIDC.ClientID) == "" {
|
if strings.TrimSpace(c.OIDC.ClientID) == "" {
|
||||||
return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true")
|
return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ func TestValidateLinuxDoFrontendRedirectURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
|
func TestValidateLinuxDoAllowsDisablingPKCEForCompatibility(t *testing.T) {
|
||||||
resetViperWithJWTSecret(t)
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
@@ -363,11 +363,8 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
|
|||||||
cfg.LinuxDo.UsePKCE = false
|
cfg.LinuxDo.UsePKCE = false
|
||||||
|
|
||||||
err = cfg.Validate()
|
err = cfg.Validate()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatalf("Validate() expected error when token_auth_method=none and use_pkce=false, got nil")
|
t.Fatalf("Validate() expected LinuxDo config without PKCE to pass for compatibility, got: %v", err)
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "linuxdo_connect.use_pkce") {
|
|
||||||
t.Fatalf("Validate() expected use_pkce error, got: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +424,35 @@ func TestValidateOIDCAllowsIssuerOnlyEndpointsWithDiscoveryFallback(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateOIDCAllowsDisablingPKCEAndIDTokenValidation(t *testing.T) {
|
||||||
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.OIDC.Enabled = true
|
||||||
|
cfg.OIDC.ClientID = "oidc-client"
|
||||||
|
cfg.OIDC.ClientSecret = "oidc-secret"
|
||||||
|
cfg.OIDC.IssuerURL = "https://issuer.example.com"
|
||||||
|
cfg.OIDC.AuthorizeURL = "https://issuer.example.com/auth"
|
||||||
|
cfg.OIDC.TokenURL = "https://issuer.example.com/token"
|
||||||
|
cfg.OIDC.UserInfoURL = "https://issuer.example.com/userinfo"
|
||||||
|
cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback"
|
||||||
|
cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback"
|
||||||
|
cfg.OIDC.Scopes = "openid email profile"
|
||||||
|
cfg.OIDC.UsePKCE = false
|
||||||
|
cfg.OIDC.ValidateIDToken = false
|
||||||
|
cfg.OIDC.JWKSURL = ""
|
||||||
|
cfg.OIDC.AllowedSigningAlgs = ""
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() expected OIDC config without PKCE/id_token validation to pass for compatibility, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDefaultDashboardCacheConfig(t *testing.T) {
|
func TestLoadDefaultDashboardCacheConfig(t *testing.T) {
|
||||||
resetViperWithJWTSecret(t)
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
|
|||||||
@@ -653,20 +653,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode(req.WeChatConnectMode)
|
req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode(req.WeChatConnectMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.WeChatConnectRedirectURL == "" {
|
if req.WeChatConnectOpenEnabled || req.WeChatConnectMPEnabled {
|
||||||
response.BadRequest(c, "WeChat Redirect URL is required when enabled")
|
if req.WeChatConnectRedirectURL == "" {
|
||||||
return
|
response.BadRequest(c, "WeChat Redirect URL is required when web oauth is enabled")
|
||||||
}
|
return
|
||||||
if err := config.ValidateAbsoluteHTTPURL(req.WeChatConnectRedirectURL); err != nil {
|
}
|
||||||
response.BadRequest(c, "WeChat Redirect URL must be an absolute http(s) URL")
|
if err := config.ValidateAbsoluteHTTPURL(req.WeChatConnectRedirectURL); err != nil {
|
||||||
return
|
response.BadRequest(c, "WeChat Redirect URL must be an absolute http(s) URL")
|
||||||
}
|
return
|
||||||
if req.WeChatConnectFrontendRedirectURL == "" {
|
}
|
||||||
req.WeChatConnectFrontendRedirectURL = "/auth/wechat/callback"
|
if req.WeChatConnectFrontendRedirectURL == "" {
|
||||||
}
|
req.WeChatConnectFrontendRedirectURL = "/auth/wechat/callback"
|
||||||
if err := config.ValidateFrontendRedirectURL(req.WeChatConnectFrontendRedirectURL); err != nil {
|
}
|
||||||
response.BadRequest(c, "WeChat Frontend Redirect URL is invalid")
|
if err := config.ValidateFrontendRedirectURL(req.WeChatConnectFrontendRedirectURL); err != nil {
|
||||||
return
|
response.BadRequest(c, "WeChat Frontend Redirect URL is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,14 +751,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, "OIDC scopes must contain openid")
|
response.BadRequest(c, "OIDC scopes must contain openid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !req.OIDCConnectUsePKCE {
|
|
||||||
response.BadRequest(c, "OIDC PKCE must be enabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !req.OIDCConnectValidateIDToken {
|
|
||||||
response.BadRequest(c, "OIDC ID Token validation must be enabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch req.OIDCConnectTokenAuthMethod {
|
switch req.OIDCConnectTokenAuthMethod {
|
||||||
case "", "client_secret_post", "client_secret_basic", "none":
|
case "", "client_secret_post", "client_secret_basic", "none":
|
||||||
default:
|
default:
|
||||||
@@ -767,7 +761,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.OIDCConnectAllowedSigningAlgs == "" {
|
if req.OIDCConnectValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" {
|
||||||
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,13 +123,16 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
|||||||
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
|
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
verifier, err := oauth.GenerateCodeVerifier()
|
codeChallenge := ""
|
||||||
if err != nil {
|
if cfg.UsePKCE {
|
||||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err))
|
verifier, err := oauth.GenerateCodeVerifier()
|
||||||
return
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codeChallenge = oauth.GenerateCodeChallenge(verifier)
|
||||||
|
setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
||||||
}
|
}
|
||||||
codeChallenge := oauth.GenerateCodeChallenge(verifier)
|
|
||||||
setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
|
||||||
|
|
||||||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||||||
if redirectURI == "" {
|
if redirectURI == "" {
|
||||||
@@ -200,10 +203,13 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName)
|
intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName)
|
||||||
intent = normalizeOAuthIntent(intent)
|
intent = normalizeOAuthIntent(intent)
|
||||||
|
|
||||||
codeVerifier, _ := readCookieDecoded(c, linuxDoOAuthVerifierCookie)
|
codeVerifier := ""
|
||||||
if codeVerifier == "" {
|
if cfg.UsePKCE {
|
||||||
redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
|
codeVerifier, _ = readCookieDecoded(c, linuxDoOAuthVerifierCookie)
|
||||||
return
|
if codeVerifier == "" {
|
||||||
|
redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||||||
@@ -292,25 +298,16 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if existingIdentityUser != nil {
|
if existingIdentityUser != nil {
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), existingIdentityUser.Email, username, "")
|
|
||||||
if err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
Intent: oauthIntentLogin,
|
Intent: oauthIntentLogin,
|
||||||
Identity: identityKey,
|
Identity: identityKey,
|
||||||
TargetUserID: &user.ID,
|
TargetUserID: &existingIdentityUser.ID,
|
||||||
ResolvedEmail: existingIdentityUser.Email,
|
ResolvedEmail: existingIdentityUser.Email,
|
||||||
RedirectTo: redirectTo,
|
RedirectTo: redirectTo,
|
||||||
BrowserSessionKey: browserSessionKey,
|
BrowserSessionKey: browserSessionKey,
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
UpstreamIdentityClaims: upstreamClaims,
|
||||||
CompletionResponse: map[string]any{
|
CompletionResponse: map[string]any{
|
||||||
"access_token": tokenPair.AccessToken,
|
"redirect": redirectTo,
|
||||||
"refresh_token": tokenPair.RefreshToken,
|
|
||||||
"expires_in": tokenPair.ExpiresIn,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"redirect": redirectTo,
|
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
@@ -546,7 +543,9 @@ func linuxDoExchangeCode(
|
|||||||
form.Set("client_id", cfg.ClientID)
|
form.Set("client_id", cfg.ClientID)
|
||||||
form.Set("code", code)
|
form.Set("code", code)
|
||||||
form.Set("redirect_uri", redirectURI)
|
form.Set("redirect_uri", redirectURI)
|
||||||
form.Set("code_verifier", codeVerifier)
|
if strings.TrimSpace(codeVerifier) != "" {
|
||||||
|
form.Set("code_verifier", codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
r := client.R().
|
r := client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
@@ -699,8 +698,10 @@ func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, cod
|
|||||||
q.Set("scope", cfg.Scopes)
|
q.Set("scope", cfg.Scopes)
|
||||||
}
|
}
|
||||||
q.Set("state", state)
|
q.Set("state", state)
|
||||||
q.Set("code_challenge", codeChallenge)
|
if strings.TrimSpace(codeChallenge) != "" {
|
||||||
q.Set("code_challenge_method", "S256")
|
q.Set("code_challenge", codeChallenge)
|
||||||
|
q.Set("code_challenge_method", "S256")
|
||||||
|
}
|
||||||
|
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
|
|||||||
@@ -171,6 +171,80 @@ func TestLinuxDoOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
|
|||||||
require.Equal(t, int64(42), userID)
|
require.Equal(t, int64(42), userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthStartOmitsPKCEWhenDisabled(t *testing.T) {
|
||||||
|
handler := newLinuxDoOAuthTestHandler(t, false, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: "https://connect.linux.do/oauth/authorize",
|
||||||
|
TokenURL: "https://connect.linux.do/oauth/token",
|
||||||
|
UserInfoURL: "https://connect.linux.do/api/user",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/start?redirect=/dashboard", nil)
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthStart(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.NotContains(t, recorder.Header().Get("Location"), "code_challenge=")
|
||||||
|
require.Nil(t, findCookie(recorder.Result().Cookies(), linuxDoOAuthVerifierCookie))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxDoOAuthCallbackAllowsMissingVerifierWhenPKCEDisabled(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
require.NoError(t, r.ParseForm())
|
||||||
|
require.Empty(t, r.PostForm.Get("code_verifier"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
case "/userinfo":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"compat-subject","username":"linuxdo_user","name":"LinuxDo Display"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "linuxdo-client",
|
||||||
|
ClientSecret: "linuxdo-secret",
|
||||||
|
AuthorizeURL: upstream.URL + "/authorize",
|
||||||
|
TokenURL: upstream.URL + "/token",
|
||||||
|
UserInfoURL: upstream.URL + "/userinfo",
|
||||||
|
Scopes: "read",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||||
|
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: false,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=linuxdo-code&state=state-123", nil)
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.LinuxDoOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
||||||
|
require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
|
||||||
|
}
|
||||||
|
|
||||||
func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
||||||
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -327,7 +401,10 @@ func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t
|
|||||||
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, "/dashboard", completion["redirect"])
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
require.NotEmpty(t, completion["access_token"])
|
_, hasAccessToken := completion["access_token"]
|
||||||
|
require.False(t, hasAccessToken)
|
||||||
|
_, hasRefreshToken := completion["refresh_token"]
|
||||||
|
require.False(t, hasRefreshToken)
|
||||||
require.Nil(t, completion["error"])
|
require.Nil(t, completion["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,21 +157,25 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
codeChallenge := ""
|
codeChallenge := ""
|
||||||
verifier, genErr := oauth.GenerateCodeVerifier()
|
if cfg.UsePKCE {
|
||||||
if genErr != nil {
|
verifier, genErr := oauth.GenerateCodeVerifier()
|
||||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(genErr))
|
if genErr != nil {
|
||||||
return
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(genErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codeChallenge = oauth.GenerateCodeChallenge(verifier)
|
||||||
|
oidcSetCookie(c, oidcOAuthVerifierCookie, encodeCookieValue(verifier), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||||
}
|
}
|
||||||
codeChallenge = oauth.GenerateCodeChallenge(verifier)
|
|
||||||
oidcSetCookie(c, oidcOAuthVerifierCookie, encodeCookieValue(verifier), oidcOAuthCookieMaxAgeSec, secureCookie)
|
|
||||||
|
|
||||||
nonce := ""
|
nonce := ""
|
||||||
nonce, err = oauth.GenerateState()
|
if cfg.ValidateIDToken {
|
||||||
if err != nil {
|
nonce, err = oauth.GenerateState()
|
||||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_NONCE_GEN_FAILED", "failed to generate oauth nonce").WithCause(err))
|
if err != nil {
|
||||||
return
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_NONCE_GEN_FAILED", "failed to generate oauth nonce").WithCause(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oidcSetCookie(c, oidcOAuthNonceCookie, encodeCookieValue(nonce), oidcOAuthCookieMaxAgeSec, secureCookie)
|
||||||
}
|
}
|
||||||
oidcSetCookie(c, oidcOAuthNonceCookie, encodeCookieValue(nonce), oidcOAuthCookieMaxAgeSec, secureCookie)
|
|
||||||
|
|
||||||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||||||
if redirectURI == "" {
|
if redirectURI == "" {
|
||||||
@@ -244,17 +248,21 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
intent = normalizeOAuthIntent(intent)
|
intent = normalizeOAuthIntent(intent)
|
||||||
|
|
||||||
codeVerifier := ""
|
codeVerifier := ""
|
||||||
codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie)
|
if cfg.UsePKCE {
|
||||||
if codeVerifier == "" {
|
codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie)
|
||||||
redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
|
if codeVerifier == "" {
|
||||||
return
|
redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedNonce := ""
|
expectedNonce := ""
|
||||||
expectedNonce, _ = readCookieDecoded(c, oidcOAuthNonceCookie)
|
if cfg.ValidateIDToken {
|
||||||
if expectedNonce == "" {
|
expectedNonce, _ = readCookieDecoded(c, oidcOAuthNonceCookie)
|
||||||
redirectOAuthError(c, frontendCallback, "missing_nonce", "missing oauth nonce", "")
|
if expectedNonce == "" {
|
||||||
return
|
redirectOAuthError(c, frontendCallback, "missing_nonce", "missing oauth nonce", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||||||
@@ -284,16 +292,19 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(tokenResp.IDToken) == "" {
|
var idClaims *oidcIDTokenClaims
|
||||||
redirectOAuthError(c, frontendCallback, "missing_id_token", "missing id_token", "")
|
if cfg.ValidateIDToken {
|
||||||
return
|
if strings.TrimSpace(tokenResp.IDToken) == "" {
|
||||||
}
|
redirectOAuthError(c, frontendCallback, "missing_id_token", "missing id_token", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
idClaims, err := oidcParseAndValidateIDToken(c.Request.Context(), cfg, tokenResp.IDToken, expectedNonce)
|
idClaims, err = oidcParseAndValidateIDToken(c.Request.Context(), cfg, tokenResp.IDToken, expectedNonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[OIDC OAuth] id_token validation failed: %v", err)
|
log.Printf("[OIDC OAuth] id_token validation failed: %v", err)
|
||||||
redirectOAuthError(c, frontendCallback, "invalid_id_token", "failed to validate id_token", "")
|
redirectOAuthError(c, frontendCallback, "invalid_id_token", "failed to validate id_token", "")
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userInfoClaims, err := oidcFetchUserInfo(c.Request.Context(), cfg, tokenResp)
|
userInfoClaims, err := oidcFetchUserInfo(c.Request.Context(), cfg, tokenResp)
|
||||||
@@ -303,7 +314,10 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := strings.TrimSpace(idClaims.Subject)
|
subject := ""
|
||||||
|
if idClaims != nil {
|
||||||
|
subject = strings.TrimSpace(idClaims.Subject)
|
||||||
|
}
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = strings.TrimSpace(userInfoClaims.Subject)
|
subject = strings.TrimSpace(userInfoClaims.Subject)
|
||||||
}
|
}
|
||||||
@@ -311,7 +325,10 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
redirectOAuthError(c, frontendCallback, "missing_subject", "missing subject claim", "")
|
redirectOAuthError(c, frontendCallback, "missing_subject", "missing subject claim", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
issuer := strings.TrimSpace(idClaims.Issuer)
|
issuer := ""
|
||||||
|
if idClaims != nil {
|
||||||
|
issuer = strings.TrimSpace(idClaims.Issuer)
|
||||||
|
}
|
||||||
if issuer == "" {
|
if issuer == "" {
|
||||||
issuer = strings.TrimSpace(cfg.IssuerURL)
|
issuer = strings.TrimSpace(cfg.IssuerURL)
|
||||||
}
|
}
|
||||||
@@ -321,21 +338,34 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emailVerified := userInfoClaims.EmailVerified
|
emailVerified := userInfoClaims.EmailVerified
|
||||||
if emailVerified == nil {
|
if emailVerified == nil && idClaims != nil {
|
||||||
emailVerified = idClaims.EmailVerified
|
emailVerified = idClaims.EmailVerified
|
||||||
}
|
}
|
||||||
if userInfoClaims.Subject != "" && idClaims.Subject != "" && strings.TrimSpace(userInfoClaims.Subject) != strings.TrimSpace(idClaims.Subject) {
|
if idClaims != nil && userInfoClaims.Subject != "" && idClaims.Subject != "" && strings.TrimSpace(userInfoClaims.Subject) != strings.TrimSpace(idClaims.Subject) {
|
||||||
redirectOAuthError(c, frontendCallback, "subject_mismatch", "userinfo subject does not match id_token", "")
|
redirectOAuthError(c, frontendCallback, "subject_mismatch", "userinfo subject does not match id_token", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
identityKey := oidcIdentityKey(issuer, subject)
|
identityKey := oidcIdentityKey(issuer, subject)
|
||||||
compatEmail := strings.TrimSpace(firstNonEmpty(userInfoClaims.Email, idClaims.Email))
|
compatEmail := strings.TrimSpace(userInfoClaims.Email)
|
||||||
|
if compatEmail == "" && idClaims != nil {
|
||||||
|
compatEmail = strings.TrimSpace(idClaims.Email)
|
||||||
|
}
|
||||||
email := oidcSyntheticEmailFromIdentityKey(identityKey)
|
email := oidcSyntheticEmailFromIdentityKey(identityKey)
|
||||||
username := firstNonEmpty(
|
username := firstNonEmpty(
|
||||||
userInfoClaims.Username,
|
userInfoClaims.Username,
|
||||||
idClaims.PreferredUsername,
|
func() string {
|
||||||
idClaims.Name,
|
if idClaims != nil {
|
||||||
|
return idClaims.PreferredUsername
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
func() string {
|
||||||
|
if idClaims != nil {
|
||||||
|
return idClaims.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
oidcFallbackUsername(subject),
|
oidcFallbackUsername(subject),
|
||||||
)
|
)
|
||||||
identityRef := service.PendingAuthIdentityKey{
|
identityRef := service.PendingAuthIdentityKey{
|
||||||
@@ -350,7 +380,12 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
"issuer": issuer,
|
"issuer": issuer,
|
||||||
"email_verified": emailVerified != nil && *emailVerified,
|
"email_verified": emailVerified != nil && *emailVerified,
|
||||||
"provider_fallback": strings.TrimSpace(cfg.ProviderName),
|
"provider_fallback": strings.TrimSpace(cfg.ProviderName),
|
||||||
"suggested_display_name": firstNonEmpty(userInfoClaims.DisplayName, idClaims.Name, username),
|
"suggested_display_name": firstNonEmpty(userInfoClaims.DisplayName, func() string {
|
||||||
|
if idClaims != nil {
|
||||||
|
return idClaims.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(), username),
|
||||||
"suggested_avatar_url": userInfoClaims.AvatarURL,
|
"suggested_avatar_url": userInfoClaims.AvatarURL,
|
||||||
}
|
}
|
||||||
if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
|
if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
|
||||||
@@ -387,25 +422,16 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if existingIdentityUser != nil {
|
if existingIdentityUser != nil {
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), existingIdentityUser.Email, username, "")
|
|
||||||
if err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
Intent: oauthIntentLogin,
|
Intent: oauthIntentLogin,
|
||||||
Identity: identityRef,
|
Identity: identityRef,
|
||||||
TargetUserID: &user.ID,
|
TargetUserID: &existingIdentityUser.ID,
|
||||||
ResolvedEmail: existingIdentityUser.Email,
|
ResolvedEmail: existingIdentityUser.Email,
|
||||||
RedirectTo: redirectTo,
|
RedirectTo: redirectTo,
|
||||||
BrowserSessionKey: browserSessionKey,
|
BrowserSessionKey: browserSessionKey,
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
UpstreamIdentityClaims: upstreamClaims,
|
||||||
CompletionResponse: map[string]any{
|
CompletionResponse: map[string]any{
|
||||||
"access_token": tokenPair.AccessToken,
|
"redirect": redirectTo,
|
||||||
"refresh_token": tokenPair.RefreshToken,
|
|
||||||
"expires_in": tokenPair.ExpiresIn,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"redirect": redirectTo,
|
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
@@ -670,7 +696,9 @@ func oidcExchangeCode(
|
|||||||
form.Set("client_id", cfg.ClientID)
|
form.Set("client_id", cfg.ClientID)
|
||||||
form.Set("code", code)
|
form.Set("code", code)
|
||||||
form.Set("redirect_uri", redirectURI)
|
form.Set("redirect_uri", redirectURI)
|
||||||
form.Set("code_verifier", codeVerifier)
|
if strings.TrimSpace(codeVerifier) != "" {
|
||||||
|
form.Set("code_verifier", codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
r := client.R().
|
r := client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
@@ -872,9 +900,13 @@ func buildOIDCAuthorizeURL(cfg config.OIDCConnectConfig, state, nonce, codeChall
|
|||||||
q.Set("scope", cfg.Scopes)
|
q.Set("scope", cfg.Scopes)
|
||||||
}
|
}
|
||||||
q.Set("state", state)
|
q.Set("state", state)
|
||||||
q.Set("nonce", nonce)
|
if strings.TrimSpace(nonce) != "" {
|
||||||
q.Set("code_challenge", codeChallenge)
|
q.Set("nonce", nonce)
|
||||||
q.Set("code_challenge_method", "S256")
|
}
|
||||||
|
if strings.TrimSpace(codeChallenge) != "" {
|
||||||
|
q.Set("code_challenge", codeChallenge)
|
||||||
|
q.Set("code_challenge_method", "S256")
|
||||||
|
}
|
||||||
|
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
|
|||||||
@@ -186,6 +186,89 @@ func TestOIDCOAuthBindStartRedirectsAndSetsBindCookies(t *testing.T) {
|
|||||||
require.Equal(t, int64(84), userID)
|
require.Equal(t, int64(84), userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCOAuthStartOmitsPKCEAndNonceWhenDisabled(t *testing.T) {
|
||||||
|
handler := newOIDCOAuthTestHandler(t, false, config.OIDCConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "oidc-client",
|
||||||
|
ClientSecret: "oidc-secret",
|
||||||
|
IssuerURL: "https://issuer.example.com",
|
||||||
|
AuthorizeURL: "https://issuer.example.com/oauth/authorize",
|
||||||
|
TokenURL: "https://issuer.example.com/oauth/token",
|
||||||
|
UserInfoURL: "https://issuer.example.com/oauth/userinfo",
|
||||||
|
Scopes: "openid profile email",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
|
||||||
|
FrontendRedirectURL: "/auth/oidc/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: false,
|
||||||
|
ValidateIDToken: false,
|
||||||
|
RequireEmailVerified: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/start?redirect=/dashboard", nil)
|
||||||
|
|
||||||
|
handler.OIDCOAuthStart(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
require.NotContains(t, location, "code_challenge=")
|
||||||
|
require.NotContains(t, location, "nonce=")
|
||||||
|
require.Nil(t, findCookie(recorder.Result().Cookies(), oidcOAuthVerifierCookie))
|
||||||
|
require.Nil(t, findCookie(recorder.Result().Cookies(), oidcOAuthNonceCookie))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOIDCOAuthCallbackAllowsOptionalPKCEAndIDTokenValidation(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
require.NoError(t, r.ParseForm())
|
||||||
|
require.Empty(t, r.PostForm.Get("code_verifier"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"oidc-access","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
case "/userinfo":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sub":"oidc-subject-compat","preferred_username":"oidc_user","name":"OIDC Display","email":"oidc@example.com"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
handler, client := newOIDCOAuthHandlerAndClient(t, false, config.OIDCConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "oidc-client",
|
||||||
|
ClientSecret: "oidc-secret",
|
||||||
|
IssuerURL: "https://issuer.example.com",
|
||||||
|
AuthorizeURL: upstream.URL + "/authorize",
|
||||||
|
TokenURL: upstream.URL + "/token",
|
||||||
|
UserInfoURL: upstream.URL + "/userinfo",
|
||||||
|
Scopes: "openid profile email",
|
||||||
|
RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
|
||||||
|
FrontendRedirectURL: "/auth/oidc/callback",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
UsePKCE: false,
|
||||||
|
ValidateIDToken: false,
|
||||||
|
RequireEmailVerified: false,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-123", nil)
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.OIDCOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
|
||||||
|
require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
|
||||||
|
}
|
||||||
|
|
||||||
func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
|
func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
|
||||||
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||||
Subject: "oidc-subject-login",
|
Subject: "oidc-subject-login",
|
||||||
@@ -250,7 +333,10 @@ func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *t
|
|||||||
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, "/dashboard", completion["redirect"])
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
require.NotEmpty(t, completion["access_token"])
|
_, hasAccessToken := completion["access_token"]
|
||||||
|
require.False(t, hasAccessToken)
|
||||||
|
_, hasRefreshToken := completion["refresh_token"]
|
||||||
|
require.False(t, hasRefreshToken)
|
||||||
require.Nil(t, completion["error"])
|
require.Nil(t, completion["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,12 +279,7 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), existingIdentityUser.Email, username, "")
|
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, existingIdentityUser.Email, redirectTo, browserSessionKey, upstreamClaims, nil, nil, &existingIdentityUser.ID); err != nil {
|
||||||
if err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, existingIdentityUser.Email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil, &user.ID); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,86 @@ func TestWeChatOAuthCallbackFallsBackToOpenIDWhenUnionIDMissingInSingleChannelMo
|
|||||||
require.Equal(t, "third_party_signup", completion["choice_reason"])
|
require.Equal(t, "third_party_signup", completion["choice_reason"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUserWithoutStoredTokens(t *testing.T) {
|
||||||
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
|
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||||
|
wechatOAuthUserInfoURL = originalUserInfoURL
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/oauth2/access_token"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-123","unionid":"union-456","scope":"snsapi_login"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/sns/userinfo"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"openid":"openid-123","unionid":"union-456","nickname":"WeChat Display","headimgurl":"https://cdn.example/wechat-login.png"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||||
|
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
|
||||||
|
|
||||||
|
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("open", "wx-open-app", "wx-open-secret", "https://app.example.com/auth/wechat/callback"))
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
existingUser, err := client.User.Create().
|
||||||
|
SetEmail(wechatSyntheticEmail("union-456")).
|
||||||
|
SetUsername("wechat-existing-user").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.AuthIdentity.Create().
|
||||||
|
SetUserID(existingUser.ID).
|
||||||
|
SetProviderType("wechat").
|
||||||
|
SetProviderKey(wechatOAuthProviderKey).
|
||||||
|
SetProviderSubject("union-456").
|
||||||
|
SetMetadata(map[string]any{"username": "wechat-existing-user"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/callback?code=wechat-code&state=state-123", nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthStateCookieName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthRedirectCookieName, "/dashboard"))
|
||||||
|
req.AddCookie(encodedCookie(wechatOAuthModeCookieName, "open"))
|
||||||
|
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-123"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.WeChatOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
require.Equal(t, "https://app.example.com/auth/wechat/callback", recorder.Header().Get("Location"))
|
||||||
|
|
||||||
|
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||||
|
require.NotNil(t, sessionCookie)
|
||||||
|
|
||||||
|
session, err := client.PendingAuthSession.Query().
|
||||||
|
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||||
|
Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||||
|
require.NotNil(t, session.TargetUserID)
|
||||||
|
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||||
|
require.Equal(t, existingUser.Email, session.ResolvedEmail)
|
||||||
|
|
||||||
|
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||||
|
require.Equal(t, "/dashboard", completion["redirect"])
|
||||||
|
_, hasAccessToken := completion["access_token"]
|
||||||
|
require.False(t, hasAccessToken)
|
||||||
|
_, hasRefreshToken := completion["refresh_token"]
|
||||||
|
require.False(t, hasRefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) {
|
func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) {
|
||||||
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string
|
|||||||
mpReady := mpEnabled && webRedirectReady && mpAppID != "" && mpAppSecret != ""
|
mpReady := mpEnabled && webRedirectReady && mpAppID != "" && mpAppSecret != ""
|
||||||
mobileReady := mobileEnabled && mobileAppID != "" && mobileAppSecret != ""
|
mobileReady := mobileEnabled && mobileAppID != "" && mobileAppSecret != ""
|
||||||
|
|
||||||
return openReady || mpReady || mobileReady, openReady, mpReady, mobileReady
|
return openReady || mpReady, openReady, mpReady, mobileReady
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
||||||
@@ -1693,8 +1693,6 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
} else {
|
} else {
|
||||||
result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken
|
result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken
|
||||||
}
|
}
|
||||||
result.OIDCConnectUsePKCE = true
|
|
||||||
result.OIDCConnectValidateIDToken = true
|
|
||||||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||||||
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v)
|
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v)
|
||||||
} else {
|
} else {
|
||||||
@@ -2196,8 +2194,6 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
|
|||||||
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
|
||||||
effective.RedirectURL = strings.TrimSpace(v)
|
effective.RedirectURL = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
effective.UsePKCE = true
|
|
||||||
|
|
||||||
if !effective.Enabled {
|
if !effective.Enabled {
|
||||||
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||||||
}
|
}
|
||||||
@@ -2421,8 +2417,6 @@ func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.
|
|||||||
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
|
||||||
effective.ValidateIDToken = raw == "true"
|
effective.ValidateIDToken = raw == "true"
|
||||||
}
|
}
|
||||||
effective.UsePKCE = true
|
|
||||||
effective.ValidateIDToken = true
|
|
||||||
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
|
||||||
effective.AllowedSigningAlgs = strings.TrimSpace(v)
|
effective.AllowedSigningAlgs = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,3 +101,47 @@ func TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery(t *testi
|
|||||||
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/userinfo", got.UserInfoURL)
|
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/userinfo", got.UserInfoURL)
|
||||||
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/certs", got.JWKSURL)
|
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/certs", got.JWKSURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSettingService_ParseSettings_PreservesOptionalOIDCCompatibilityFlags(t *testing.T) {
|
||||||
|
svc := NewSettingService(&settingOIDCRepoStub{values: map[string]string{}}, &config.Config{})
|
||||||
|
|
||||||
|
got := svc.parseSettings(map[string]string{
|
||||||
|
SettingKeyOIDCConnectEnabled: "true",
|
||||||
|
SettingKeyOIDCConnectUsePKCE: "false",
|
||||||
|
SettingKeyOIDCConnectValidateIDToken: "false",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.False(t, got.OIDCConnectUsePKCE)
|
||||||
|
require.False(t, got.OIDCConnectValidateIDToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOIDCConnectOAuthConfig_AllowsCompatibilityFlagsToDisablePKCEAndIDTokenValidation(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
OIDC: config.OIDCConnectConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ProviderName: "OIDC",
|
||||||
|
ClientID: "oidc-client",
|
||||||
|
ClientSecret: "oidc-secret",
|
||||||
|
IssuerURL: "https://issuer.example.com",
|
||||||
|
AuthorizeURL: "https://issuer.example.com/auth",
|
||||||
|
TokenURL: "https://issuer.example.com/token",
|
||||||
|
UserInfoURL: "https://issuer.example.com/userinfo",
|
||||||
|
RedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback",
|
||||||
|
FrontendRedirectURL: "/auth/oidc/callback",
|
||||||
|
Scopes: "openid email profile",
|
||||||
|
TokenAuthMethod: "client_secret_post",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &settingOIDCRepoStub{values: map[string]string{
|
||||||
|
SettingKeyOIDCConnectEnabled: "true",
|
||||||
|
SettingKeyOIDCConnectUsePKCE: "false",
|
||||||
|
SettingKeyOIDCConnectValidateIDToken: "false",
|
||||||
|
}}
|
||||||
|
svc := NewSettingService(repo, cfg)
|
||||||
|
|
||||||
|
got, err := svc.GetOIDCConnectOAuthConfig(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, got.UsePKCE)
|
||||||
|
require.False(t, got.ValidateIDToken)
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,3 +112,23 @@ func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *
|
|||||||
require.True(t, settings.WeChatOAuthOpenEnabled)
|
require.True(t, settings.WeChatOAuthOpenEnabled)
|
||||||
require.True(t, settings.WeChatOAuthMPEnabled)
|
require.True(t, settings.WeChatOAuthMPEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSettingService_GetPublicSettings_DoesNotExposeMobileOnlyWeChatAsWebOAuthAvailable(t *testing.T) {
|
||||||
|
svc := NewSettingService(&settingPublicRepoStub{
|
||||||
|
values: map[string]string{
|
||||||
|
SettingKeyWeChatConnectEnabled: "true",
|
||||||
|
SettingKeyWeChatConnectMobileEnabled: "true",
|
||||||
|
SettingKeyWeChatConnectMode: "mobile",
|
||||||
|
SettingKeyWeChatConnectMobileAppID: "wx-mobile-app",
|
||||||
|
SettingKeyWeChatConnectMobileAppSecret: "wx-mobile-secret",
|
||||||
|
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
|
||||||
|
},
|
||||||
|
}, &config.Config{})
|
||||||
|
|
||||||
|
settings, err := svc.GetPublicSettings(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, settings.WeChatOAuthEnabled)
|
||||||
|
require.False(t, settings.WeChatOAuthOpenEnabled)
|
||||||
|
require.False(t, settings.WeChatOAuthMPEnabled)
|
||||||
|
require.True(t, settings.WeChatOAuthMobileEnabled)
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,12 +248,59 @@ func (s *UserService) GetProfileIdentitySummaries(ctx context.Context, userID in
|
|||||||
return UserIdentitySummarySet{}, err
|
return UserIdentitySummarySet{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserIdentitySummarySet{
|
summaries := UserIdentitySummarySet{
|
||||||
Email: s.buildEmailIdentitySummary(user, records),
|
Email: s.buildEmailIdentitySummary(user, records),
|
||||||
LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records),
|
LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records),
|
||||||
OIDC: s.buildProviderIdentitySummary("oidc", user, records),
|
OIDC: s.buildProviderIdentitySummary("oidc", user, records),
|
||||||
WeChat: s.buildProviderIdentitySummary("wechat", user, records),
|
WeChat: s.buildProviderIdentitySummary("wechat", user, records),
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
s.applyExplicitProviderAvailability(ctx, &summaries)
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, summaries *UserIdentitySummarySet) {
|
||||||
|
if s == nil || summaries == nil || s.settingRepo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := s.settingRepo.GetMultiple(ctx, []string{
|
||||||
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
|
SettingKeyOIDCConnectEnabled,
|
||||||
|
SettingKeyWeChatConnectEnabled,
|
||||||
|
SettingKeyWeChatConnectOpenEnabled,
|
||||||
|
SettingKeyWeChatConnectMPEnabled,
|
||||||
|
SettingKeyWeChatConnectMobileEnabled,
|
||||||
|
SettingKeyWeChatConnectMode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
|
||||||
|
disableIdentityBindAction(&summaries.LinuxDo)
|
||||||
|
}
|
||||||
|
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
|
||||||
|
disableIdentityBindAction(&summaries.OIDC)
|
||||||
|
}
|
||||||
|
if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok && strings.TrimSpace(raw) != "" {
|
||||||
|
if raw != "true" {
|
||||||
|
disableIdentityBindAction(&summaries.WeChat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openEnabled, mpEnabled, _ := parseWeChatConnectCapabilitySettings(settings, true, settings[SettingKeyWeChatConnectMode])
|
||||||
|
if !openEnabled && !mpEnabled {
|
||||||
|
disableIdentityBindAction(&summaries.WeChat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableIdentityBindAction(summary *UserIdentitySummary) {
|
||||||
|
if summary == nil || summary.Bound {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary.CanBind = false
|
||||||
|
summary.BindStartPath = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUserIdentityBindingRequest) (*StartUserIdentityBindingResult, error) {
|
func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUserIdentityBindingRequest) (*StartUserIdentityBindingResult, error) {
|
||||||
|
|||||||
@@ -51,6 +51,44 @@ type mockUserRepoTxState struct {
|
|||||||
deleteAvatarIDs []int64
|
deleteAvatarIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockUserSettingRepo struct {
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) Get(context.Context, string) (*Setting, error) {
|
||||||
|
panic("unexpected Get call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) GetValue(context.Context, string) (string, error) {
|
||||||
|
panic("unexpected GetValue call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) Set(context.Context, string, string) error {
|
||||||
|
panic("unexpected Set call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := m.values[key]; ok {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) SetMultiple(context.Context, map[string]string) error {
|
||||||
|
panic("unexpected SetMultiple call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) GetAll(context.Context) (map[string]string, error) {
|
||||||
|
panic("unexpected GetAll call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserSettingRepo) Delete(context.Context, string) error {
|
||||||
|
panic("unexpected Delete call")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
||||||
func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
|
func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
|
||||||
if m.getByIDErr != nil {
|
if m.getByIDErr != nil {
|
||||||
@@ -382,6 +420,35 @@ func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testin
|
|||||||
require.True(t, summaries.LinuxDo.CanBind)
|
require.True(t, summaries.LinuxDo.CanBind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProfileIdentitySummaries_HidesBindActionWhenProviderExplicitlyDisabled(t *testing.T) {
|
||||||
|
repo := &mockUserRepo{
|
||||||
|
getByIDUser: &User{
|
||||||
|
ID: 15,
|
||||||
|
Email: "alice@example.com",
|
||||||
|
},
|
||||||
|
identities: []UserAuthIdentityRecord{
|
||||||
|
{
|
||||||
|
ProviderType: "email",
|
||||||
|
ProviderKey: "email",
|
||||||
|
ProviderSubject: "alice@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
settingRepo := &mockUserSettingRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
SettingKeyLinuxDoConnectEnabled: "false",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewUserService(repo, settingRepo, nil, nil)
|
||||||
|
|
||||||
|
summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 15, repo.getByIDUser)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, summaries.LinuxDo.Bound)
|
||||||
|
require.False(t, summaries.LinuxDo.CanBind)
|
||||||
|
require.Empty(t, summaries.LinuxDo.BindStartPath)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
||||||
repo := &mockUserRepo{}
|
repo := &mockUserRepo{}
|
||||||
svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
|
svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
|
||||||
|
|||||||
@@ -362,6 +362,16 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
|
|||||||
return binding
|
return binding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProviderEnabledForBinding(provider: BindableProvider): boolean {
|
||||||
|
if (provider === 'linuxdo') {
|
||||||
|
return props.linuxdoEnabled
|
||||||
|
}
|
||||||
|
if (provider === 'oidc') {
|
||||||
|
return props.oidcEnabled
|
||||||
|
}
|
||||||
|
return resolvedWeChatBinding.value.mode !== null
|
||||||
|
}
|
||||||
|
|
||||||
const providerItems = computed(() => [
|
const providerItems = computed(() => [
|
||||||
{
|
{
|
||||||
provider: 'email' as const,
|
provider: 'email' as const,
|
||||||
@@ -375,7 +385,10 @@ const providerItems = computed(() => [
|
|||||||
provider: 'linuxdo' as const,
|
provider: 'linuxdo' as const,
|
||||||
label: t('profile.authBindings.providers.linuxdo'),
|
label: t('profile.authBindings.providers.linuxdo'),
|
||||||
bound: getBindingStatus('linuxdo'),
|
bound: getBindingStatus('linuxdo'),
|
||||||
canBind: getBindingDetails('linuxdo')?.can_bind ?? (props.linuxdoEnabled && !getBindingStatus('linuxdo')),
|
canBind:
|
||||||
|
!getBindingStatus('linuxdo') &&
|
||||||
|
isProviderEnabledForBinding('linuxdo') &&
|
||||||
|
(getBindingDetails('linuxdo')?.can_bind ?? true),
|
||||||
canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind),
|
canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind),
|
||||||
details: getBindingDetails('linuxdo'),
|
details: getBindingDetails('linuxdo'),
|
||||||
},
|
},
|
||||||
@@ -383,7 +396,10 @@ const providerItems = computed(() => [
|
|||||||
provider: 'oidc' as const,
|
provider: 'oidc' as const,
|
||||||
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||||
bound: getBindingStatus('oidc'),
|
bound: getBindingStatus('oidc'),
|
||||||
canBind: getBindingDetails('oidc')?.can_bind ?? (props.oidcEnabled && !getBindingStatus('oidc')),
|
canBind:
|
||||||
|
!getBindingStatus('oidc') &&
|
||||||
|
isProviderEnabledForBinding('oidc') &&
|
||||||
|
(getBindingDetails('oidc')?.can_bind ?? true),
|
||||||
canUnbind: Boolean(getBindingStatus('oidc') && getBindingDetails('oidc')?.can_unbind),
|
canUnbind: Boolean(getBindingStatus('oidc') && getBindingDetails('oidc')?.can_unbind),
|
||||||
details: getBindingDetails('oidc'),
|
details: getBindingDetails('oidc'),
|
||||||
},
|
},
|
||||||
@@ -391,7 +407,10 @@ const providerItems = computed(() => [
|
|||||||
provider: 'wechat' as const,
|
provider: 'wechat' as const,
|
||||||
label: t('profile.authBindings.providers.wechat'),
|
label: t('profile.authBindings.providers.wechat'),
|
||||||
bound: getBindingStatus('wechat'),
|
bound: getBindingStatus('wechat'),
|
||||||
canBind: getBindingDetails('wechat')?.can_bind ?? (resolvedWeChatBinding.value.mode !== null && !getBindingStatus('wechat')),
|
canBind:
|
||||||
|
!getBindingStatus('wechat') &&
|
||||||
|
isProviderEnabledForBinding('wechat') &&
|
||||||
|
(getBindingDetails('wechat')?.can_bind ?? true),
|
||||||
canUnbind: Boolean(getBindingStatus('wechat') && getBindingDetails('wechat')?.can_unbind),
|
canUnbind: Boolean(getBindingStatus('wechat') && getBindingDetails('wechat')?.can_unbind),
|
||||||
details: getBindingDetails('wechat'),
|
details: getBindingDetails('wechat'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -474,4 +474,26 @@ describe('ProfileIdentityBindingsSection', () => {
|
|||||||
expect(userApiMocks.unbindAuthIdentity).toHaveBeenCalledWith('linuxdo')
|
expect(userApiMocks.unbindAuthIdentity).toHaveBeenCalledWith('linuxdo')
|
||||||
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Not bound')
|
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Not bound')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides bind actions when provider details say bindable but the provider is disabled', () => {
|
||||||
|
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
user: createUser({
|
||||||
|
auth_bindings: {
|
||||||
|
linuxdo: { bound: false, can_bind: true },
|
||||||
|
oidc: { bound: false, can_bind: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
linuxdoEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
wechatEnabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="profile-binding-linuxdo-action"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="profile-binding-oidc-action"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2032,7 +2032,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
v-model="form.oidc_connect_use_pkce"
|
v-model="form.oidc_connect_use_pkce"
|
||||||
:disabled="true"
|
data-testid="oidc-connect-use-pkce"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2046,7 +2046,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
v-model="form.oidc_connect_validate_id_token"
|
v-model="form.oidc_connect_validate_id_token"
|
||||||
:disabled="true"
|
data-testid="oidc-connect-validate-id-token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4961,8 +4961,8 @@ const form = reactive<SettingsForm>({
|
|||||||
oidc_connect_redirect_url: "",
|
oidc_connect_redirect_url: "",
|
||||||
oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
|
oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
|
||||||
oidc_connect_token_auth_method: "client_secret_post",
|
oidc_connect_token_auth_method: "client_secret_post",
|
||||||
oidc_connect_use_pkce: true,
|
oidc_connect_use_pkce: false,
|
||||||
oidc_connect_validate_id_token: true,
|
oidc_connect_validate_id_token: false,
|
||||||
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
|
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
|
||||||
oidc_connect_clock_skew_seconds: 120,
|
oidc_connect_clock_skew_seconds: 120,
|
||||||
oidc_connect_require_email_verified: false,
|
oidc_connect_require_email_verified: false,
|
||||||
@@ -5846,8 +5846,8 @@ async function saveSettings() {
|
|||||||
oidc_connect_frontend_redirect_url:
|
oidc_connect_frontend_redirect_url:
|
||||||
form.oidc_connect_frontend_redirect_url,
|
form.oidc_connect_frontend_redirect_url,
|
||||||
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
|
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
|
||||||
oidc_connect_use_pkce: true,
|
oidc_connect_use_pkce: form.oidc_connect_use_pkce,
|
||||||
oidc_connect_validate_id_token: true,
|
oidc_connect_validate_id_token: form.oidc_connect_validate_id_token,
|
||||||
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
|
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
|
||||||
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
|
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
|
||||||
oidc_connect_require_email_verified:
|
oidc_connect_require_email_verified:
|
||||||
|
|||||||
@@ -776,4 +776,28 @@ describe("admin SettingsView wechat connect controls", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(wrapper.text()).toContain("首次绑定时授权");
|
expect(wrapper.text()).toContain("首次绑定时授权");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves optional OIDC compatibility flags instead of forcing them on save", async () => {
|
||||||
|
getSettings.mockResolvedValueOnce({
|
||||||
|
...baseSettingsResponse,
|
||||||
|
oidc_connect_enabled: true,
|
||||||
|
oidc_connect_use_pkce: false,
|
||||||
|
oidc_connect_validate_id_token: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountView();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await openSecurityTab(wrapper);
|
||||||
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(updateSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
oidc_connect_use_pkce: false,
|
||||||
|
oidc_connect_validate_id_token: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user