feat: Support non-email user identifiers for Azure AD

Add userIdentifierClaim configuration option to support Azure AD users
without email addresses. This allows using alternative JWT claims like
"sub", "oid", "upn", or "preferred_username" for user identification.

- Default behavior uses "email" claim (backward compatible)
- Falls back to "sub" claim if configured claim is missing
- allowedUsers matches against the configured claim value
- allowedUserDomains only applies when using email-based identification

Fixes #95
This commit is contained in:
2025-12-08 13:21:53 +00:00
parent bbcde3ef9c
commit a316a98e4f
12 changed files with 508 additions and 95 deletions
+29 -12
View File
@@ -15,7 +15,8 @@ type OAuthHandler struct {
tokenExchanger TokenExchanger
tokenVerifier TokenVerifier
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
isAllowedDomainFunc func(email string) bool
isAllowedUserFunc func(userIdentifier string) bool // validates user authorization
userIdentifierClaim string // JWT claim to use for user identification
redirURLPath string
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
}
@@ -77,16 +78,22 @@ type TokenResponse struct {
// NewOAuthHandler creates a new OAuth handler
func NewOAuthHandler(logger Logger, sessionManager SessionManager, tokenExchanger TokenExchanger,
tokenVerifier TokenVerifier, extractClaimsFunc func(string) (map[string]interface{}, error),
isAllowedDomainFunc func(string) bool, redirURLPath string,
isAllowedUserFunc func(string) bool, userIdentifierClaim string, redirURLPath string,
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int)) *OAuthHandler {
// Default to "email" for backward compatibility
if userIdentifierClaim == "" {
userIdentifierClaim = "email"
}
return &OAuthHandler{
logger: logger,
sessionManager: sessionManager,
tokenExchanger: tokenExchanger,
tokenVerifier: tokenVerifier,
extractClaimsFunc: extractClaimsFunc,
isAllowedDomainFunc: isAllowedDomainFunc,
isAllowedUserFunc: isAllowedUserFunc,
userIdentifierClaim: userIdentifierClaim,
redirURLPath: redirURLPath,
sendErrorResponseFunc: sendErrorResponseFunc,
}
@@ -225,15 +232,25 @@ func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request,
return
}
email, _ := claims["email"].(string)
if email == "" {
h.logger.Errorf("Email claim missing or empty in token during callback")
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError)
return
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
userIdentifier, _ := claims[h.userIdentifierClaim].(string)
if userIdentifier == "" {
// Try "sub" as fallback since it's required by OIDC spec
if h.userIdentifierClaim != "sub" {
userIdentifier, _ = claims["sub"].(string)
}
if userIdentifier == "" {
h.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", h.userIdentifierClaim)
h.sendErrorResponseFunc(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
return
}
h.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", h.userIdentifierClaim)
}
if !h.isAllowedDomainFunc(email) {
h.logger.Errorf("Disallowed email domain during callback: %s", email)
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden)
// Validate user authorization
if !h.isAllowedUserFunc(userIdentifier) {
h.logger.Errorf("User not authorized during callback: %s", userIdentifier)
h.sendErrorResponseFunc(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
return
}
@@ -242,7 +259,7 @@ func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request,
h.sendErrorResponseFunc(rw, req, "Failed to update session", http.StatusInternalServerError)
return
}
session.SetEmail(email)
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
session.SetIDToken(tokenResponse.IDToken)
session.SetAccessToken(tokenResponse.AccessToken)
session.SetRefreshToken(tokenResponse.RefreshToken)
+25 -25
View File
@@ -108,11 +108,11 @@ func TestOAuthHandler_NewOAuthHandler(t *testing.T) {
return map[string]interface{}{"email": "[email protected]", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
isAllowedUser := func(userIdentifier string) bool { return true }
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowedUser, "email", "/callback", sendError)
if handler == nil {
t.Fatal("Expected handler to be created, got nil")
@@ -151,7 +151,7 @@ func TestOAuthHandler_HandleCallback_SessionError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test&state=test", nil)
rw := httptest.NewRecorder()
@@ -190,7 +190,7 @@ func TestOAuthHandler_HandleCallback_ProviderError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
// Test with error parameter
req := httptest.NewRequest("GET", "/callback?error=access_denied&error_description=User%20denied%20access", nil)
@@ -230,7 +230,7 @@ func TestOAuthHandler_HandleCallback_MissingState(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test", nil)
rw := httptest.NewRecorder()
@@ -265,7 +265,7 @@ func TestOAuthHandler_HandleCallback_MissingCSRF(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -300,7 +300,7 @@ func TestOAuthHandler_HandleCallback_CSRFMismatch(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -335,7 +335,7 @@ func TestOAuthHandler_HandleCallback_MissingCode(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?state=test-state", nil)
rw := httptest.NewRecorder()
@@ -370,7 +370,7 @@ func TestOAuthHandler_HandleCallback_TokenExchangeError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -406,7 +406,7 @@ func TestOAuthHandler_HandleCallback_TokenVerificationError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -444,7 +444,7 @@ func TestOAuthHandler_HandleCallback_ClaimsExtractionError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -483,7 +483,7 @@ func TestOAuthHandler_HandleCallback_MissingNonceInToken(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -521,7 +521,7 @@ func TestOAuthHandler_HandleCallback_MissingNonceInSession(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -559,7 +559,7 @@ func TestOAuthHandler_HandleCallback_NonceMismatch(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -591,13 +591,13 @@ func TestOAuthHandler_HandleCallback_MissingEmail(t *testing.T) {
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Email missing in token") {
t.Errorf("Expected error message to contain 'Email missing in token', got '%s'", msg)
if !strings.Contains(msg, "User identifier missing in token") {
t.Errorf("Expected error message to contain 'User identifier missing in token', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -629,13 +629,13 @@ func TestOAuthHandler_HandleCallback_DisallowedDomain(t *testing.T) {
if code != http.StatusForbidden {
t.Errorf("Expected status %d, got %d", http.StatusForbidden, code)
}
if !strings.Contains(msg, "Email domain not allowed") {
t.Errorf("Expected error message to contain 'Email domain not allowed', got '%s'", msg)
if !strings.Contains(msg, "User not authorized") {
t.Errorf("Expected error message to contain 'User not authorized', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -677,7 +677,7 @@ func TestOAuthHandler_HandleCallback_SessionSaveError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -719,7 +719,7 @@ func TestOAuthHandler_HandleCallback_SetAuthenticatedError(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -760,7 +760,7 @@ func TestOAuthHandler_HandleCallback_Success(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -843,7 +843,7 @@ func TestOAuthHandler_HandleCallback_SuccessDefaultRedirect(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
@@ -884,7 +884,7 @@ func TestOAuthHandler_HandleCallback_RedirectURLPathExcluded(t *testing.T) {
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/callback", sendError)
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()