mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-28 13:42:33 +00:00
Add ExternalIDClaim option for OAuth2 OIDC auth source (#37229)
This PR adds an External ID Claim Name configuration field to the OIDC auth source. When set, Gitea uses the specified JWT claim as the user's `ExternalID` instead of the default `sub` claim. This PR fixes the bug when migrating from Azure AD V2 to OIDC. When an admin migrates the same auth source to OIDC, goth's `openidConnect` provider defaults to using the `sub` claim as `UserID`. However, Azure AD's `sub` is a pairwise identifier: > `sub`: The subject is a pairwise identifier and is unique to an application ID. If a single user signs into two different apps using two different client IDs, those apps receive two different values for the subject claim. https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#payload-claims As a result, every existing user appears as a new account after migration. To fix this issue, Gitea should use `oid` claim for `UserID`. > `oid`: This ID uniquely identifies the user across applications - two different applications signing in the same user receives the same value in the oid claim. Note: The `oid` claim is not included in Azure AD tokens by default. The `profile` scope must be added to the Scopes field of the auth source.
This commit is contained in:
@@ -3180,6 +3180,8 @@
|
||||
"admin.auths.oauth2_required_claim_name_helper": "Set this name to restrict login from this source to users with a claim with this name",
|
||||
"admin.auths.oauth2_required_claim_value": "Required Claim Value",
|
||||
"admin.auths.oauth2_required_claim_value_helper": "Set this value to restrict login from this source to users with a claim with this name and value",
|
||||
"admin.auths.open_id_connect_external_id_claim": "External ID Claim Name (Optional)",
|
||||
"admin.auths.open_id_connect_external_id_claim_helper": "Claim name to use as the user's external identity. Defaults to \"sub\". For Azure AD / Entra ID, set this to \"oid\" to maintain continuity when migrating from the Azure AD V2 provider. Note: the \"oid\" claim requires the \"profile\" scope to be included in the Scopes field above.",
|
||||
"admin.auths.oauth2_group_claim_name": "Claim name providing group names for this source. (Optional)",
|
||||
"admin.auths.oauth2_full_name_claim_name": "Full Name Claim Name. (Optional — if set, the user's full name will always be synchronized with this claim)",
|
||||
"admin.auths.oauth2_ssh_public_key_claim_name": "SSH Public Key Claim Name",
|
||||
|
||||
@@ -205,6 +205,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
|
||||
|
||||
SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
|
||||
FullNameClaimName: form.Oauth2FullNameClaimName,
|
||||
ExternalIDClaim: form.OpenIDConnectExternalIDClaim,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,14 @@ func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, so
|
||||
provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
|
||||
if err != nil {
|
||||
log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
|
||||
return nil, err
|
||||
}
|
||||
return provider, err
|
||||
if source.ExternalIDClaim != "" {
|
||||
// UserIdClaims is a fallback list; goth returns the first non-empty matching claim.
|
||||
// A single entry is sufficient because the admin explicitly chooses one claim (e.g. "oid" for Azure AD).
|
||||
provider.UserIdClaims = []string{source.ExternalIDClaim}
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// CustomURLSettings returns the custom url settings for this provider
|
||||
|
||||
@@ -30,6 +30,7 @@ type Source struct {
|
||||
|
||||
SSHPublicKeyClaimName string
|
||||
FullNameClaimName string
|
||||
ExternalIDClaim string
|
||||
}
|
||||
|
||||
// FromDB fills up an OAuth2Config from serialized format.
|
||||
|
||||
@@ -88,6 +88,7 @@ type AuthenticationForm struct {
|
||||
Oauth2GroupTeamMapRemoval bool
|
||||
Oauth2SSHPublicKeyClaimName string
|
||||
Oauth2FullNameClaimName string
|
||||
OpenIDConnectExternalIDClaim string
|
||||
|
||||
// SSPI
|
||||
SSPIAutoCreateUsers bool
|
||||
|
||||
@@ -330,6 +330,11 @@
|
||||
<label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
|
||||
<input name="oauth2_ssh_public_key_claim_name" value="{{$cfg.SSHPublicKeyClaimName}}" placeholder="sshpubkey">
|
||||
</div>
|
||||
<div class="open_id_connect_external_id_claim field">
|
||||
<label for="open_id_connect_external_id_claim">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim"}}</label>
|
||||
<input id="open_id_connect_external_id_claim" name="open_id_connect_external_id_claim" value="{{$cfg.ExternalIDClaim}}" placeholder="sub">
|
||||
<p class="help">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim_helper"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
|
||||
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}">
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
<label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
|
||||
<input name="oauth2_ssh_public_key_claim_name" value="{{.oauth2_ssh_public_key_claim_name}}" placeholder="sshpubkey">
|
||||
</div>
|
||||
<div class="open_id_connect_external_id_claim field">
|
||||
<label for="open_id_connect_external_id_claim">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim"}}</label>
|
||||
<input id="open_id_connect_external_id_claim" name="open_id_connect_external_id_claim" value="{{.open_id_connect_external_id_claim}}" placeholder="sub">
|
||||
<p class="help">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim_helper"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
|
||||
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}">
|
||||
|
||||
201
tests/integration/auth_oauth2_test.go
Normal file
201
tests/integration/auth_oauth2_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/services/auth/source/oauth2"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMigrateAzureADV2ToOIDC simulates a login source migration from the Azure AD V2 OAuth2 provider to the OpenID Connect provider,
|
||||
// and verifies that setting ExternalIDClaim = "oid" restores account continuity.
|
||||
//
|
||||
// Background: Azure AD V2 (goth's azureadv2 provider) fetches the user profile from Microsoft Graph API (/v1.0/me)
|
||||
// and uses the "id" field - the stable Object ID (OID) - as gothUser.UserID. That OID is stored as ExternalID in external_login_user.
|
||||
//
|
||||
// When the admin migrates the same source to OpenID Connect, the goth openidConnect provider defaults to ["sub"] for UserIdClaims.
|
||||
// Azure AD's "sub" is pairwise (unique per application), so it differs from the OID that was previously stored,
|
||||
// causing every existing user to appear as a new account.
|
||||
//
|
||||
// Setting ExternalIDClaim = "oid" on the OIDC source overrides UserIdClaims to ["oid"],
|
||||
// so the same OID is extracted and matched against the existing rows, restoring continuity.
|
||||
func TestMigrateAzureADV2ToOIDC(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||
// Use UserID (gothUser.UserID) as the Gitea username so that different ExternalID values produce different, non-conflicting usernames.
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
|
||||
|
||||
const (
|
||||
sourceName = "test-migrate-azure"
|
||||
|
||||
// oidValue is the stable Azure AD Object ID, used as ExternalID by the Azure AD V2 provider.
|
||||
oidValue = "oid-object-id-stable"
|
||||
|
||||
// subValue is the pairwise sub issued by Azure AD for OpenID Connect; it differs from oidValue and would produce a separate account if used.
|
||||
subValue = "sub-pairwise-value"
|
||||
)
|
||||
|
||||
// The fake OIDC server issues tokens containing both sub and oid claims, mirroring what Azure AD v2.0 returns.
|
||||
srv := newFakeOIDCServer(t, subValue, oidValue)
|
||||
|
||||
// --- Step 1: Establish the legacy Azure AD V2 state ---
|
||||
// Create an azureadv2 auth source. In production this would have been the source used before the migration.
|
||||
addOAuth2Source(t, sourceName, oauth2.Source{
|
||||
Provider: "azureadv2",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
CustomURLMapping: &oauth2.CustomURLMapping{
|
||||
Tenant: "test-tenant-id",
|
||||
},
|
||||
})
|
||||
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a user to represent the "legacy" account that was originally registered through the Azure AD V2 provider.
|
||||
legacyUser := &user_model.User{
|
||||
Name: "legacy-azure-user",
|
||||
Email: "legacy-azure-user@example.com",
|
||||
}
|
||||
require.NoError(t, user_model.CreateUser(t.Context(), legacyUser, &user_model.Meta{}))
|
||||
require.NoError(t, user_model.LinkExternalToUser(t.Context(), legacyUser, &user_model.ExternalLoginUser{
|
||||
ExternalID: oidValue,
|
||||
UserID: legacyUser.ID,
|
||||
LoginSourceID: authSource.ID,
|
||||
Provider: authSource.Name,
|
||||
}))
|
||||
|
||||
// --- Step 2: Migrate the source to OIDC without ExternalIDClaim ---
|
||||
// The provider type of the OAuth2 source is changed from azureadv2 to openidConnect.
|
||||
// Without ExternalIDClaim the goth provider defaults to "sub", which does not match the stored OID, so every sign-in creates a fresh account.
|
||||
authSource.Cfg = &oauth2.Source{
|
||||
Provider: "openidConnect",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
|
||||
// ExternalIDClaim intentionally not set; goth defaults to "sub".
|
||||
}
|
||||
err = auth_model.UpdateSource(t.Context(), authSource)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("without ExternalIDClaim: legacy user is NOT matched", func(t *testing.T) {
|
||||
// Confirm the external user with ExternalID=subValue doesn't exist.
|
||||
unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: subValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||
|
||||
doOIDCSignIn(t, sourceName)
|
||||
|
||||
// "sub" is now the ExternalID - a new user was auto-registered.
|
||||
subEntry := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: subValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||
// The auto-registered user is NOT the legacy user.
|
||||
assert.NotEqual(t, legacyUser.ID, subEntry.UserID)
|
||||
})
|
||||
|
||||
// --- Step 3: Set ExternalIDClaim = "oid" to restore account continuity ---
|
||||
// Set ExternalIDClaim = "oid" so that the OIDC source extracts the same Object ID that the Azure AD V2 provider previously stored.
|
||||
authSource.Cfg.(*oauth2.Source).ExternalIDClaim = "oid"
|
||||
err = auth_model.UpdateSource(t.Context(), authSource)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("with ExternalIDClaim=oid: legacy user IS matched", func(t *testing.T) {
|
||||
// Confirm the legacy oid row has no RawData yet - it was created directly via LinkExternalToUser in setup, without going through an OAuth flow.
|
||||
oidEntry := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: oidValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||
require.Nil(t, oidEntry.RawData)
|
||||
|
||||
doOIDCSignIn(t, sourceName)
|
||||
|
||||
// After sign-in, RawData should contain both "oid" and "name".
|
||||
oidEntry = unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: oidValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||
assert.Equal(t, oidValue, oidEntry.RawData["oid"])
|
||||
assert.Equal(t, "OIDC Test User", oidEntry.RawData["name"])
|
||||
|
||||
// The matched user must still be the original legacy user.
|
||||
assert.Equal(t, legacyUser.ID, oidEntry.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
// newFakeOIDCServer starts an httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow:
|
||||
func newFakeOIDCServer(t *testing.T, sub, oid string) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration": // discovery document
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": srv.URL,
|
||||
"authorization_endpoint": srv.URL + "/authorize",
|
||||
"token_endpoint": srv.URL + "/token",
|
||||
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||
})
|
||||
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
|
||||
claims := map[string]any{
|
||||
"iss": srv.URL,
|
||||
"aud": "test-client-id",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"sub": sub,
|
||||
"oid": oid,
|
||||
}
|
||||
payload, _ := json.Marshal(claims)
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
||||
|
||||
// build a JWT-shaped string whose payload encodes claims.
|
||||
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
|
||||
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "fake-access-token",
|
||||
"token_type": "Bearer",
|
||||
"id_token": idToken,
|
||||
})
|
||||
case "/userinfo":
|
||||
// sub MUST match the id_token sub; goth rejects mismatches.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sub": sub,
|
||||
"email": sub + "@example.com",
|
||||
"name": "OIDC Test User",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// doOIDCSignIn runs a mock OIDC sign-in flow for the given auth source.
|
||||
func doOIDCSignIn(t *testing.T, sourceName string) {
|
||||
t.Helper()
|
||||
session := emptyTestSession(t)
|
||||
|
||||
// Step 1: initiate login
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
|
||||
|
||||
// Step 2: extract the UUID state that Gitea embedded in the redirect URL.
|
||||
location := resp.Header().Get("Location")
|
||||
u, err := url.Parse(location)
|
||||
require.NoError(t, err)
|
||||
state := u.Query().Get("state")
|
||||
require.NotEmpty(t, state, "redirect to OIDC provider must include state")
|
||||
|
||||
// Step 3: simulate the provider redirecting back.
|
||||
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
|
||||
session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ function initAdminAuthentication() {
|
||||
}
|
||||
|
||||
function onOAuth2Change(applyDefaultValues: boolean) {
|
||||
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
|
||||
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
@@ -88,6 +88,7 @@ function initAdminAuthentication() {
|
||||
case 'openidConnect':
|
||||
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
|
||||
showElem('.open_id_connect_auto_discovery_url');
|
||||
showElem('.open_id_connect_external_id_claim');
|
||||
break;
|
||||
default: {
|
||||
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
|
||||
|
||||
Reference in New Issue
Block a user