Skip to content

Commit b1bfca3

Browse files
authored
Add ExternalIDClaim option for OAuth2 OIDC auth source (go-gitea#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.
1 parent 4a2bba9 commit b1bfca3

9 files changed

Lines changed: 225 additions & 2 deletions

File tree

options/locale/locale_en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3180,6 +3180,8 @@
31803180
"admin.auths.oauth2_required_claim_name_helper": "Set this name to restrict login from this source to users with a claim with this name",
31813181
"admin.auths.oauth2_required_claim_value": "Required Claim Value",
31823182
"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",
3183+
"admin.auths.open_id_connect_external_id_claim": "External ID Claim Name (Optional)",
3184+
"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.",
31833185
"admin.auths.oauth2_group_claim_name": "Claim name providing group names for this source. (Optional)",
31843186
"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)",
31853187
"admin.auths.oauth2_ssh_public_key_claim_name": "SSH Public Key Claim Name",

routers/web/admin/auths.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
205205

206206
SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
207207
FullNameClaimName: form.Oauth2FullNameClaimName,
208+
ExternalIDClaim: form.OpenIDConnectExternalIDClaim,
208209
}
209210
}
210211

services/auth/source/oauth2/providers_openid.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, so
4646
provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
4747
if err != nil {
4848
log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
49+
return nil, err
4950
}
50-
return provider, err
51+
if source.ExternalIDClaim != "" {
52+
// UserIdClaims is a fallback list; goth returns the first non-empty matching claim.
53+
// A single entry is sufficient because the admin explicitly chooses one claim (e.g. "oid" for Azure AD).
54+
provider.UserIdClaims = []string{source.ExternalIDClaim}
55+
}
56+
return provider, nil
5157
}
5258

5359
// CustomURLSettings returns the custom url settings for this provider

services/auth/source/oauth2/source.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Source struct {
3030

3131
SSHPublicKeyClaimName string
3232
FullNameClaimName string
33+
ExternalIDClaim string
3334
}
3435

3536
// FromDB fills up an OAuth2Config from serialized format.

services/forms/auth_form.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type AuthenticationForm struct {
8888
Oauth2GroupTeamMapRemoval bool
8989
Oauth2SSHPublicKeyClaimName string
9090
Oauth2FullNameClaimName string
91+
OpenIDConnectExternalIDClaim string
9192

9293
// SSPI
9394
SSPIAutoCreateUsers bool

templates/admin/auth/edit.tmpl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,11 @@
330330
<label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
331331
<input name="oauth2_ssh_public_key_claim_name" value="{{$cfg.SSHPublicKeyClaimName}}" placeholder="sshpubkey">
332332
</div>
333+
<div class="open_id_connect_external_id_claim field">
334+
<label for="open_id_connect_external_id_claim">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim"}}</label>
335+
<input id="open_id_connect_external_id_claim" name="open_id_connect_external_id_claim" value="{{$cfg.ExternalIDClaim}}" placeholder="sub">
336+
<p class="help">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim_helper"}}</p>
337+
</div>
333338
<div class="field">
334339
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
335340
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}">

templates/admin/auth/source/oauth.tmpl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@
8888
<label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
8989
<input name="oauth2_ssh_public_key_claim_name" value="{{.oauth2_ssh_public_key_claim_name}}" placeholder="sshpubkey">
9090
</div>
91+
<div class="open_id_connect_external_id_claim field">
92+
<label for="open_id_connect_external_id_claim">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim"}}</label>
93+
<input id="open_id_connect_external_id_claim" name="open_id_connect_external_id_claim" value="{{.open_id_connect_external_id_claim}}" placeholder="sub">
94+
<p class="help">{{ctx.Locale.Tr "admin.auths.open_id_connect_external_id_claim_helper"}}</p>
95+
</div>
9196
<div class="field">
9297
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
9398
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}">
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package integration
5+
6+
import (
7+
"encoding/base64"
8+
"fmt"
9+
"net/http"
10+
"net/http/httptest"
11+
"net/url"
12+
"testing"
13+
"time"
14+
15+
auth_model "code.gitea.io/gitea/models/auth"
16+
"code.gitea.io/gitea/models/unittest"
17+
user_model "code.gitea.io/gitea/models/user"
18+
"code.gitea.io/gitea/modules/json"
19+
"code.gitea.io/gitea/modules/setting"
20+
"code.gitea.io/gitea/modules/test"
21+
"code.gitea.io/gitea/services/auth/source/oauth2"
22+
"code.gitea.io/gitea/tests"
23+
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
// TestMigrateAzureADV2ToOIDC simulates a login source migration from the Azure AD V2 OAuth2 provider to the OpenID Connect provider,
29+
// and verifies that setting ExternalIDClaim = "oid" restores account continuity.
30+
//
31+
// Background: Azure AD V2 (goth's azureadv2 provider) fetches the user profile from Microsoft Graph API (/v1.0/me)
32+
// and uses the "id" field - the stable Object ID (OID) - as gothUser.UserID. That OID is stored as ExternalID in external_login_user.
33+
//
34+
// When the admin migrates the same source to OpenID Connect, the goth openidConnect provider defaults to ["sub"] for UserIdClaims.
35+
// Azure AD's "sub" is pairwise (unique per application), so it differs from the OID that was previously stored,
36+
// causing every existing user to appear as a new account.
37+
//
38+
// Setting ExternalIDClaim = "oid" on the OIDC source overrides UserIdClaims to ["oid"],
39+
// so the same OID is extracted and matched against the existing rows, restoring continuity.
40+
func TestMigrateAzureADV2ToOIDC(t *testing.T) {
41+
defer tests.PrepareTestEnv(t)()
42+
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
43+
// Use UserID (gothUser.UserID) as the Gitea username so that different ExternalID values produce different, non-conflicting usernames.
44+
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
45+
46+
const (
47+
sourceName = "test-migrate-azure"
48+
49+
// oidValue is the stable Azure AD Object ID, used as ExternalID by the Azure AD V2 provider.
50+
oidValue = "oid-object-id-stable"
51+
52+
// subValue is the pairwise sub issued by Azure AD for OpenID Connect; it differs from oidValue and would produce a separate account if used.
53+
subValue = "sub-pairwise-value"
54+
)
55+
56+
// The fake OIDC server issues tokens containing both sub and oid claims, mirroring what Azure AD v2.0 returns.
57+
srv := newFakeOIDCServer(t, subValue, oidValue)
58+
59+
// --- Step 1: Establish the legacy Azure AD V2 state ---
60+
// Create an azureadv2 auth source. In production this would have been the source used before the migration.
61+
addOAuth2Source(t, sourceName, oauth2.Source{
62+
Provider: "azureadv2",
63+
ClientID: "test-client-id",
64+
ClientSecret: "test-client-secret",
65+
CustomURLMapping: &oauth2.CustomURLMapping{
66+
Tenant: "test-tenant-id",
67+
},
68+
})
69+
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
70+
require.NoError(t, err)
71+
72+
// Create a user to represent the "legacy" account that was originally registered through the Azure AD V2 provider.
73+
legacyUser := &user_model.User{
74+
Name: "legacy-azure-user",
75+
Email: "legacy-azure-user@example.com",
76+
}
77+
require.NoError(t, user_model.CreateUser(t.Context(), legacyUser, &user_model.Meta{}))
78+
require.NoError(t, user_model.LinkExternalToUser(t.Context(), legacyUser, &user_model.ExternalLoginUser{
79+
ExternalID: oidValue,
80+
UserID: legacyUser.ID,
81+
LoginSourceID: authSource.ID,
82+
Provider: authSource.Name,
83+
}))
84+
85+
// --- Step 2: Migrate the source to OIDC without ExternalIDClaim ---
86+
// The provider type of the OAuth2 source is changed from azureadv2 to openidConnect.
87+
// Without ExternalIDClaim the goth provider defaults to "sub", which does not match the stored OID, so every sign-in creates a fresh account.
88+
authSource.Cfg = &oauth2.Source{
89+
Provider: "openidConnect",
90+
ClientID: "test-client-id",
91+
ClientSecret: "test-client-secret",
92+
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
93+
// ExternalIDClaim intentionally not set; goth defaults to "sub".
94+
}
95+
err = auth_model.UpdateSource(t.Context(), authSource)
96+
require.NoError(t, err)
97+
98+
t.Run("without ExternalIDClaim: legacy user is NOT matched", func(t *testing.T) {
99+
// Confirm the external user with ExternalID=subValue doesn't exist.
100+
unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: subValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
101+
102+
doOIDCSignIn(t, sourceName)
103+
104+
// "sub" is now the ExternalID - a new user was auto-registered.
105+
subEntry := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: subValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
106+
// The auto-registered user is NOT the legacy user.
107+
assert.NotEqual(t, legacyUser.ID, subEntry.UserID)
108+
})
109+
110+
// --- Step 3: Set ExternalIDClaim = "oid" to restore account continuity ---
111+
// Set ExternalIDClaim = "oid" so that the OIDC source extracts the same Object ID that the Azure AD V2 provider previously stored.
112+
authSource.Cfg.(*oauth2.Source).ExternalIDClaim = "oid"
113+
err = auth_model.UpdateSource(t.Context(), authSource)
114+
require.NoError(t, err)
115+
116+
t.Run("with ExternalIDClaim=oid: legacy user IS matched", func(t *testing.T) {
117+
// Confirm the legacy oid row has no RawData yet - it was created directly via LinkExternalToUser in setup, without going through an OAuth flow.
118+
oidEntry := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: oidValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
119+
require.Nil(t, oidEntry.RawData)
120+
121+
doOIDCSignIn(t, sourceName)
122+
123+
// After sign-in, RawData should contain both "oid" and "name".
124+
oidEntry = unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: oidValue, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
125+
assert.Equal(t, oidValue, oidEntry.RawData["oid"])
126+
assert.Equal(t, "OIDC Test User", oidEntry.RawData["name"])
127+
128+
// The matched user must still be the original legacy user.
129+
assert.Equal(t, legacyUser.ID, oidEntry.UserID)
130+
})
131+
}
132+
133+
// newFakeOIDCServer starts an httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow:
134+
func newFakeOIDCServer(t *testing.T, sub, oid string) *httptest.Server {
135+
t.Helper()
136+
137+
var srv *httptest.Server
138+
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+
w.Header().Set("Content-Type", "application/json")
140+
switch r.URL.Path {
141+
case "/.well-known/openid-configuration": // discovery document
142+
_ = json.NewEncoder(w).Encode(map[string]string{
143+
"issuer": srv.URL,
144+
"authorization_endpoint": srv.URL + "/authorize",
145+
"token_endpoint": srv.URL + "/token",
146+
"userinfo_endpoint": srv.URL + "/userinfo",
147+
})
148+
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
149+
claims := map[string]any{
150+
"iss": srv.URL,
151+
"aud": "test-client-id",
152+
"exp": time.Now().Add(time.Hour).Unix(),
153+
"sub": sub,
154+
"oid": oid,
155+
}
156+
payload, _ := json.Marshal(claims)
157+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
158+
159+
// build a JWT-shaped string whose payload encodes claims.
160+
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
161+
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
162+
163+
_ = json.NewEncoder(w).Encode(map[string]any{
164+
"access_token": "fake-access-token",
165+
"token_type": "Bearer",
166+
"id_token": idToken,
167+
})
168+
case "/userinfo":
169+
// sub MUST match the id_token sub; goth rejects mismatches.
170+
_ = json.NewEncoder(w).Encode(map[string]any{
171+
"sub": sub,
172+
"email": sub + "@example.com",
173+
"name": "OIDC Test User",
174+
})
175+
default:
176+
http.NotFound(w, r)
177+
}
178+
}))
179+
t.Cleanup(srv.Close)
180+
return srv
181+
}
182+
183+
// doOIDCSignIn runs a mock OIDC sign-in flow for the given auth source.
184+
func doOIDCSignIn(t *testing.T, sourceName string) {
185+
t.Helper()
186+
session := emptyTestSession(t)
187+
188+
// Step 1: initiate login
189+
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
190+
191+
// Step 2: extract the UUID state that Gitea embedded in the redirect URL.
192+
location := resp.Header().Get("Location")
193+
u, err := url.Parse(location)
194+
require.NoError(t, err)
195+
state := u.Query().Get("state")
196+
require.NotEmpty(t, state, "redirect to OIDC provider must include state")
197+
198+
// Step 3: simulate the provider redirecting back.
199+
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
200+
session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
201+
}

web_src/js/features/admin/common.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ function initAdminAuthentication() {
7878
}
7979

8080
function onOAuth2Change(applyDefaultValues: boolean) {
81-
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
81+
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
8282
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
8383
input.removeAttribute('required');
8484
}
@@ -88,6 +88,7 @@ function initAdminAuthentication() {
8888
case 'openidConnect':
8989
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
9090
showElem('.open_id_connect_auto_discovery_url');
91+
showElem('.open_id_connect_external_id_claim');
9192
break;
9293
default: {
9394
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);

0 commit comments

Comments
 (0)