Skip to content

Commit 11e02c9

Browse files
authored
Merge pull request DSpace#3355 from 4Science/task/main/CST-15074
ORCID Login flow for private emails
2 parents 5a53cc9 + 1316540 commit 11e02c9

91 files changed

Lines changed: 3991 additions & 75 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/app/app-routes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,20 @@ export const APP_ROUTES: Route[] = [
263263
.then((m) => m.ROUTES),
264264
canActivate: [authenticatedGuard],
265265
},
266+
{
267+
path: 'external-login/:token',
268+
loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES),
269+
},
270+
{
271+
path: 'review-account/:token',
272+
loadChildren: () => import('./external-login-review-account-info-page/external-login-review-account-info-page-routes')
273+
.then((m) => m.ROUTES),
274+
},
275+
{
276+
path: 'email-confirmation',
277+
loadChildren: () => import('./external-login-email-confirmation-page/external-login-email-confirmation-page-routes')
278+
.then((m) => m.ROUTES),
279+
},
266280
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent },
267281
],
268282
},

src/app/app.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
import { ClientCookieService } from './core/services/client-cookie.service';
6666
import { ListableModule } from './core/shared/listable.module';
6767
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
68+
import { LOGIN_METHOD_FOR_DECORATOR_MAP } from './external-log-in/decorators/external-log-in.methods-decorator';
6869
import { RootModule } from './root.module';
6970
import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator';
7071
import { METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP } from './shared/metadata-representation/metadata-representation.decorator';
@@ -165,6 +166,7 @@ export const commonAppConfig: ApplicationConfig = {
165166

166167
/* Use models object so all decorators are actually called */
167168
const modelList = models;
169+
const loginMethodForDecoratorMap = LOGIN_METHOD_FOR_DECORATOR_MAP;
168170
const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP;
169171
const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP;
170172
const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import {
3+
Store,
4+
StoreModule,
5+
} from '@ngrx/store';
6+
import {
7+
MockStore,
8+
provideMockStore,
9+
} from '@ngrx/store/testing';
10+
11+
import { storeModuleConfig } from '../../app.reducer';
12+
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
13+
import { authReducer } from './auth.reducer';
14+
import { AuthMethodsService } from './auth-methods.service';
15+
import { AuthMethod } from './models/auth.method';
16+
import { AuthMethodType } from './models/auth.method-type';
17+
18+
describe('AuthMethodsService', () => {
19+
let service: AuthMethodsService;
20+
let store: MockStore;
21+
let mockAuthMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
22+
let mockAuthMethodsArray: AuthMethod[] = [
23+
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod,
24+
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod,
25+
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod,
26+
{ id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod,
27+
];
28+
29+
const initialState = {
30+
core: {
31+
auth: {
32+
authMethods: mockAuthMethodsArray,
33+
},
34+
},
35+
};
36+
37+
beforeEach(() => {
38+
TestBed.configureTestingModule({
39+
imports: [
40+
StoreModule.forRoot(authReducer, storeModuleConfig),
41+
],
42+
providers: [
43+
AuthMethodsService,
44+
provideMockStore({ initialState }),
45+
],
46+
});
47+
48+
service = TestBed.inject(AuthMethodsService);
49+
store = TestBed.inject(Store) as MockStore;
50+
51+
// Setup mock auth methods map
52+
mockAuthMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
53+
mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent);
54+
mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent);
55+
mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent);
56+
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
57+
58+
});
59+
60+
it('should be created', () => {
61+
expect(service).toBeTruthy();
62+
});
63+
64+
describe('getAuthMethods', () => {
65+
it('should return auth methods sorted by position', () => {
66+
67+
// Expected result after sorting and filtering IP auth
68+
const expected = [
69+
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
70+
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 },
71+
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
72+
];
73+
74+
service.getAuthMethods(mockAuthMethods).subscribe(result => {
75+
expect(result.length).toBe(3);
76+
expect(result).toEqual(expected);
77+
});
78+
});
79+
80+
it('should exclude specified auth method type', () => {
81+
82+
// Expected result after excluding Password auth and filtering IP auth
83+
const expected = [
84+
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
85+
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
86+
];
87+
88+
89+
service.getAuthMethods(mockAuthMethods, AuthMethodType.Password).subscribe(result => {
90+
expect(result.length).toBe(2);
91+
expect(result).toEqual(expected);
92+
});
93+
});
94+
95+
it('should always filter out IP authentication method', () => {
96+
97+
// Add IP auth to the mock methods map
98+
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
99+
100+
101+
service.getAuthMethods(mockAuthMethods).subscribe(result => {
102+
expect(result.length).toBe(3);
103+
expect(result.find(method => method.authMethodType === AuthMethodType.Ip)).toBeUndefined();
104+
});
105+
});
106+
107+
it('should handle empty auth methods array', () => {
108+
const authMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
109+
110+
111+
service.getAuthMethods(authMethods).subscribe(result => {
112+
expect(result.length).toBe(0);
113+
expect(result).toEqual([]);
114+
});
115+
});
116+
117+
it('should handle duplicate auth method types and keep only unique ones', () => {
118+
// Arrange
119+
const duplicateMethodsArray = [
120+
...mockAuthMethodsArray,
121+
{ id: 'password2', authMethodType: AuthMethodType.Password, position: 5 } as AuthMethod,
122+
];
123+
124+
125+
service.getAuthMethods(mockAuthMethods).subscribe(result => {
126+
expect(result.length).toBe(3);
127+
// Check that we only have one Password auth method
128+
const passwordMethods = result.filter(method => method.authMethodType === AuthMethodType.Password);
129+
expect(passwordMethods.length).toBe(1);
130+
});
131+
});
132+
});
133+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Injectable } from '@angular/core';
2+
import {
3+
select,
4+
Store,
5+
} from '@ngrx/store';
6+
import uniqBy from 'lodash/uniqBy';
7+
import { Observable } from 'rxjs';
8+
import { map } from 'rxjs/operators';
9+
10+
import { AppState } from '../../app.reducer';
11+
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
12+
import { rendersAuthMethodType } from '../../shared/log-in/methods/log-in.methods-decorator.utils';
13+
import { AuthMethod } from './models/auth.method';
14+
import { AuthMethodType } from './models/auth.method-type';
15+
import { getAuthenticationMethods } from './selectors';
16+
17+
@Injectable({
18+
providedIn: 'root',
19+
})
20+
/**
21+
* Service responsible for managing and filtering authentication methods.
22+
* Provides methods to retrieve and process authentication methods from the application store.
23+
*/
24+
export class AuthMethodsService {
25+
constructor(protected store: Store<AppState>) {
26+
}
27+
28+
/**
29+
* Retrieves and processes authentication methods from the store.
30+
*
31+
* @param authMethods A map of authentication method types to their corresponding components
32+
* @param excludedAuthMethod Optional authentication method type to exclude from the results
33+
* @returns An Observable of filtered and sorted authentication methods
34+
*/
35+
public getAuthMethods(
36+
authMethods: Map<AuthMethodType, AuthMethodTypeComponent>,
37+
excludedAuthMethod?: AuthMethodType,
38+
): Observable<AuthMethod[]> {
39+
return this.store.pipe(
40+
select(getAuthenticationMethods),
41+
map((methods: AuthMethod[]) => methods
42+
// ignore the given auth method if it should be excluded
43+
.filter((authMethod: AuthMethod) => excludedAuthMethod == null || authMethod.authMethodType !== excludedAuthMethod)
44+
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethods, authMethod.authMethodType) !== undefined)
45+
.sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position),
46+
),
47+
// ignore the ip authentication method when it's returned by the backend
48+
map((methods: AuthMethod[]) => uniqBy(methods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')),
49+
);
50+
}
51+
}

src/app/core/auth/auth-request.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,5 @@ export abstract class AuthRequestService {
139139
}),
140140
);
141141
}
142+
142143
}

src/app/core/auth/auth.service.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
getFirstCompletedRemoteData,
6363
} from '../shared/operators';
6464
import { PageInfo } from '../shared/page-info.model';
65+
import { URLCombiner } from '../url-combiner/url-combiner';
6566
import {
6667
CheckAuthenticationTokenAction,
6768
RefreshTokenAction,
@@ -278,7 +279,7 @@ export class AuthService {
278279
if (status.hasSucceeded) {
279280
return status.payload.specialGroups;
280281
} else {
281-
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[]));
282+
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
282283
}
283284
}),
284285
);
@@ -579,6 +580,31 @@ export class AuthService {
579580
});
580581
}
581582

583+
/**
584+
* Returns the external server redirect URL.
585+
* @param origin - The origin route.
586+
* @param redirectRoute - The redirect route.
587+
* @param location - The location.
588+
* @returns The external server redirect URL.
589+
*/
590+
getExternalServerRedirectUrl(origin: string, redirectRoute: string, location: string): string {
591+
const correctRedirectUrl = new URLCombiner(origin, redirectRoute).toString();
592+
593+
let externalServerUrl = location;
594+
const myRegexp = /\?redirectUrl=(.*)/g;
595+
const match = myRegexp.exec(location);
596+
const redirectUrlFromServer = (match && match[1]) ? match[1] : null;
597+
598+
// Check whether the current page is different from the redirect url received from rest
599+
if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) {
600+
// change the redirect url with the current page url
601+
const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`;
602+
externalServerUrl = location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
603+
}
604+
605+
return externalServerUrl;
606+
}
607+
582608
/**
583609
* Clear redirect url
584610
*/
@@ -663,5 +689,4 @@ export class AuthService {
663689
this.store.dispatch(new UnsetUserAsIdleAction());
664690
}
665691
}
666-
667692
}

src/app/core/auth/models/auth.method-type.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ export enum AuthMethodType {
66
X509 = 'x509',
77
Oidc = 'oidc',
88
Orcid = 'orcid',
9-
Saml = 'saml'
9+
Saml = 'saml',
1010
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum AuthRegistrationType {
2+
Orcid = 'ORCID',
3+
Validation = 'VALIDATION_',
4+
}

src/app/core/data/eperson-registration.service.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe('EpersonRegistrationService', () => {
105105

106106
describe('searchByToken', () => {
107107
it('should return a registration corresponding to the provided token', () => {
108-
const expected = service.searchByToken('test-token');
108+
const expected = service.searchByTokenAndUpdateData('test-token');
109109

110110
expect(expected).toBeObservable(cold('(a|)', {
111111
a: jasmine.objectContaining({
@@ -123,7 +123,7 @@ describe('EpersonRegistrationService', () => {
123123
testScheduler.run(({ cold, expectObservable }) => {
124124
rdbService.buildSingle.and.returnValue(cold('a', { a: rd }));
125125

126-
service.searchByToken('test-token');
126+
service.searchByTokenAndUpdateData('test-token');
127127

128128
expect(requestService.send).toHaveBeenCalledWith(
129129
jasmine.objectContaining({

0 commit comments

Comments
 (0)