Skip to content

Commit 3a170ce

Browse files
committed
fix: handle studioctl auth username casing
1 parent 69ad7f0 commit 3a170ce

10 files changed

Lines changed: 125 additions & 6 deletions

File tree

src/Designer/backend/src/Designer/Services/Implementation/GiteaDbStudioOidcUsernameProvider.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ TimeProvider timeProvider
1818
) : IStudioOidcUsernameProvider
1919
{
2020
private const string GiteaLookupQuery = """
21-
SELECT u.lower_name
21+
SELECT u.name
2222
FROM external_login_user elu
2323
JOIN "user" u ON elu.user_id = u.id
2424
WHERE elu.external_id = @sub
@@ -39,6 +39,17 @@ public async Task<string> ResolveUsernameAsync(string sub, PidHash pidHash, stri
3939
throw new UnauthorizedAccessException($"User account '{mapping.Username}' has been deactivated.");
4040
}
4141

42+
string? canonicalGiteaUsername = await LookupGiteaUsernameAsync(sub);
43+
if (
44+
canonicalGiteaUsername != null
45+
&& !string.Equals(mapping.Username, canonicalGiteaUsername, StringComparison.Ordinal)
46+
&& string.Equals(mapping.Username, canonicalGiteaUsername, StringComparison.OrdinalIgnoreCase)
47+
)
48+
{
49+
mapping.Username = canonicalGiteaUsername;
50+
await designerDb.SaveChangesAsync();
51+
}
52+
4253
return mapping.Username;
4354
}
4455

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System;
2+
using System.Security.Claims;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Altinn.Studio.Designer.Controllers;
6+
using Altinn.Studio.Designer.Services.Implementation;
7+
using Altinn.Studio.Designer.Services.Interfaces;
8+
using Altinn.Studio.Designer.Services.Models;
9+
using Microsoft.AspNetCore.Http;
10+
using Microsoft.AspNetCore.Mvc;
11+
using Microsoft.Extensions.Caching.Distributed;
12+
using Microsoft.Extensions.Caching.Memory;
13+
using Microsoft.Extensions.Options;
14+
using Moq;
15+
using Xunit;
16+
17+
namespace Designer.Tests.Services;
18+
19+
public class StudioctlAuthServiceTests
20+
{
21+
[Fact]
22+
public async Task Authorize_WithCanonicalAuthenticatedUsername_RedirectsToCanonicalSettingsOwner()
23+
{
24+
StudioctlAuthService service = CreateService();
25+
var controller = new StudioctlAuthController(service)
26+
{
27+
ControllerContext = new ControllerContext
28+
{
29+
HttpContext = new DefaultHttpContext
30+
{
31+
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "Jondyr")], "test")),
32+
},
33+
},
34+
};
35+
36+
IActionResult result = await controller.Authorize(
37+
"http://127.0.0.1:12345/callback",
38+
"state",
39+
"code-challenge",
40+
"studioctl prod",
41+
CancellationToken.None
42+
);
43+
44+
var redirect = Assert.IsType<RedirectResult>(result);
45+
Assert.StartsWith("/settings/Jondyr/studioctl-auth?requestId=", redirect.Url);
46+
}
47+
48+
[Fact]
49+
public async Task CreateAuthorizationRequestAsync_WithCanonicalUsername_PreservesCasingInConfirmationUrl()
50+
{
51+
StudioctlAuthService service = CreateService();
52+
var request = new StudioctlAuthorizeRequest(
53+
"http://127.0.0.1:12345/callback",
54+
"state",
55+
"code-challenge",
56+
"studioctl prod"
57+
);
58+
59+
StudioctlAuthResult<string> result = await service.CreateAuthorizationRequestAsync(
60+
"Jondyr",
61+
request,
62+
CancellationToken.None
63+
);
64+
65+
Assert.Equal(StudioctlAuthStatus.Success, result.Status);
66+
Assert.StartsWith("/settings/Jondyr/studioctl-auth?requestId=", result.Value);
67+
}
68+
69+
private static StudioctlAuthService CreateService()
70+
{
71+
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
72+
var apiKeyService = new Mock<IApiKeyService>();
73+
return new StudioctlAuthService(apiKeyService.Object, cache, TimeProvider.System);
74+
}
75+
}

src/Designer/frontend/settings/components/OwnerIndexRedirect/OwnerIndexRedirect.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ describe('OwnerIndexRedirect', () => {
6464
expect(screen.getByText('User page')).toBeInTheDocument();
6565
});
6666

67+
it('redirects to the api-keys page when owner matches the logged-in user with different casing', () => {
68+
renderOwnerIndexRedirect('/TestUser');
69+
expect(screen.getByText('User page')).toBeInTheDocument();
70+
});
71+
6772
it('renders the no-org-selected message when owner matches the logged-in user and studioOidc is disabled', () => {
6873
mockEnvironment.environment = { featureFlags: { studioOidc: false } };
6974
mockUseFeatureFlag.mockReturnValue(true);

src/Designer/frontend/settings/components/OwnerIndexRedirect/OwnerIndexRedirect.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { FeatureFlag, useFeatureFlag } from '@studio/feature-flags';
1010
import { StudioCenter, StudioPageSpinner } from '@studio/components';
1111
import { StudioPageError } from 'app-shared/components';
1212
import { useTranslation } from 'react-i18next';
13+
import { StringUtils } from '@studio/pure-functions';
1314

1415
export const OwnerIndexRedirect = () => {
1516
const { t } = useTranslation();
@@ -34,7 +35,7 @@ export const OwnerIndexRedirect = () => {
3435
if (!studioOidc && !isAdminEnabled) {
3536
return <NotFound />;
3637
}
37-
if (owner === user.login) {
38+
if (StringUtils.areCaseInsensitiveEqual(owner, user.login)) {
3839
return studioOidc ? <Navigate to={UserRoutePaths.ApiKeys} replace /> : <NoOrgSelected />;
3940
}
4041
return (

src/Designer/frontend/settings/features/user/pages/StudioctlAuth/StudioctlAuth.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ describe('StudioctlAuth', () => {
110110
).toBeInTheDocument();
111111
});
112112

113+
it('renders the authorization details when the route owner matches the logged-in user with different casing', async () => {
114+
renderStudioctlAuth({
115+
initialEntries: [`/Test-User/studioctl-auth?requestId=${requestId}`],
116+
});
117+
118+
expect(
119+
await screen.findByRole('heading', { name: textMock('settings.studioctl_auth.heading') }),
120+
).toBeInTheDocument();
121+
expect(screen.getByText(authRequest.clientName)).toBeInTheDocument();
122+
});
123+
113124
it('confirms and redirects to the callback URL', async () => {
114125
const user = userEvent.setup();
115126
const confirmStudioctlAuthRequest = jest

src/Designer/frontend/settings/features/user/pages/StudioctlAuth/StudioctlAuth.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { StudioPageError } from 'app-shared/components';
1313
import { useUserQuery } from 'app-shared/hooks/queries';
1414
import { useEnvironmentConfig } from 'app-shared/contexts/EnvironmentConfigContext';
15+
import { StringUtils } from '@studio/pure-functions';
1516
import { NotFound } from '../../../../components/NotFound/NotFound';
1617
import { useRequiredRoutePathsParams } from '../../../../hooks/useRequiredRoutePathsParams';
1718
import { useStudioctlAuthRequestQuery } from '../../hooks/queries/useStudioctlAuthRequestQuery';
@@ -26,8 +27,9 @@ export const StudioctlAuth = (): React.ReactElement => {
2627
const requestId = searchParams.get('requestId');
2728
const { environment, isPending: isEnvironmentPending } = useEnvironmentConfig();
2829
const { data: user, isPending: isUserPending, isError: isUserError } = useUserQuery();
30+
const ownerMatchesUser = StringUtils.areCaseInsensitiveEqual(owner, user?.login ?? '');
2931
const canLoadRequest =
30-
Boolean(environment?.featureFlags?.studioOidc) && owner === user?.login && Boolean(requestId);
32+
Boolean(environment?.featureFlags?.studioOidc) && ownerMatchesUser && Boolean(requestId);
3133
const {
3234
data: request,
3335
isPending: isRequestPending,
@@ -50,7 +52,7 @@ export const StudioctlAuth = (): React.ReactElement => {
5052
return <StudioPageError />;
5153
}
5254

53-
if (!environment?.featureFlags?.studioOidc || owner !== user?.login || !requestId) {
55+
if (!environment?.featureFlags?.studioOidc || !ownerMatchesUser || !requestId) {
5456
return <NotFound />;
5557
}
5658

src/Designer/frontend/settings/layouts/OrgPageLayout/OrgPageLayout.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ describe('OrgPageLayout', () => {
7070
).toBeInTheDocument();
7171
});
7272

73+
it('renders the not-found page when owner matches the logged-in user login with different casing', () => {
74+
renderOrgPageLayout({ initialEntries: ['/TestUser/bot-accounts'] });
75+
expect(
76+
screen.getByRole('heading', { name: textMock('not_found_page.heading') }),
77+
).toBeInTheDocument();
78+
});
79+
7380
it('renders the loading spinner while user data is still loading', () => {
7481
renderOrgPageLayout({
7582
initialEntries: ['/ttd/bot-accounts'],

src/Designer/frontend/settings/layouts/OrgPageLayout/OrgPageLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
77
import { useRequiredRoutePathsParams } from 'settings/hooks/useRequiredRoutePathsParams';
88
import { useEnvironmentConfig } from 'app-shared/contexts/EnvironmentConfigContext';
99
import { FeatureFlag, useFeatureFlag } from '@studio/feature-flags';
10+
import { StringUtils } from '@studio/pure-functions';
1011

1112
export const OrgPageLayout = () => {
1213
const { t } = useTranslation();
@@ -28,7 +29,7 @@ export const OrgPageLayout = () => {
2829
return <StudioPageError />;
2930
}
3031

31-
if (org === user?.login) {
32+
if (StringUtils.areCaseInsensitiveEqual(org, user?.login ?? '')) {
3233
return <NotFound />;
3334
}
3435

src/Designer/frontend/settings/layouts/UserPageLayout/UserPageLayout.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ describe('UserPageLayout', () => {
6363
expect(screen.getByText('PageLayout')).toBeInTheDocument();
6464
});
6565

66+
it('renders PageLayout when owner matches the logged-in user with different casing', () => {
67+
renderUserPageLayout({ initialEntries: ['/TestUser/profile'] });
68+
expect(screen.getByText('PageLayout')).toBeInTheDocument();
69+
});
70+
6671
it('renders the not-found page when owner does not match the logged-in user', () => {
6772
renderUserPageLayout({ initialEntries: ['/ttd/profile'] });
6873
expect(

src/Designer/frontend/settings/layouts/UserPageLayout/UserPageLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
77
import { useRequiredRoutePathsParams } from 'settings/hooks/useRequiredRoutePathsParams';
88
import { useEnvironmentConfig } from 'app-shared/contexts/EnvironmentConfigContext';
99
import { FeatureFlag, useFeatureFlag } from '@studio/feature-flags';
10+
import { StringUtils } from '@studio/pure-functions';
1011

1112
export const UserPageLayout = () => {
1213
const { t } = useTranslation();
@@ -28,7 +29,7 @@ export const UserPageLayout = () => {
2829
return <StudioPageError />;
2930
}
3031

31-
if (owner !== user?.login) {
32+
if (!StringUtils.areCaseInsensitiveEqual(owner, user?.login ?? '')) {
3233
return <NotFound />;
3334
}
3435

0 commit comments

Comments
 (0)