Skip to content

Commit c7be028

Browse files
authored
Add SciCat SSO and Dataset widget (#427)
* Add support to get scicat token for an OIDC user. Add a scicat dataset viewer widget * Addressing PR comments: some refactoring and enable/disable scicatWidget based on AppConfig
1 parent 464f103 commit c7be028

26 files changed

+512
-65
lines changed

scilog/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ typings/
6363

6464
# Cache used by TypeScript's incremental build
6565
*.tsbuildinfo
66+
67+
.vscode

scilog/package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scilog/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@angular-devkit/build-angular": "^15.2.11",
5555
"@angular/cli": "^15.2.11",
5656
"@angular/compiler-cli": "^15.2.10",
57+
"@scicatproject/scicat-sdk-ts-fetch": "^4.13.0",
5758
"@types/jasmine": "~3.6.0",
5859
"@types/jasminewd2": "~2.0.3",
5960
"@types/node": "^12.11.1",

scilog/src/app/app-config.service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@ export interface Oauth2Endpoint {
88
displayImage?: string,
99
tooltipText?: string,
1010
}
11+
export interface ScicatSettings {
12+
scicatWidgetEnabled: boolean;
13+
lbBaseURL: string;
14+
frontendBaseURL: string;
15+
}
16+
1117
export interface AppConfig {
1218
lbBaseURL?: string;
1319
oAuth2Endpoint?: Oauth2Endpoint;
1420
help?: string;
21+
scicat?: ScicatSettings;
1522
}
1623

1724
@Injectable()
@@ -24,11 +31,15 @@ export class AppConfigService {
2431
try {
2532
this.appConfig = await this.http.get("/assets/config.json").toPromise();
2633
} catch (err) {
27-
console.error("No config provided, applying defaults");
34+
console.error("No config provided, applying defaults", err);
2835
}
2936
}
3037

3138
getConfig(): AppConfig {
3239
return this.appConfig as AppConfig;
3340
}
41+
42+
getScicatSettings(): ScicatSettings | undefined {
43+
return (this.appConfig as AppConfig).scicat;
44+
}
3445
}

scilog/src/app/app-routing.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import { ViewSettingsComponent } from '@shared/settings/view-settings/view-setti
1515
import { ProfileSettingsComponent } from '@shared/settings/profile-settings/profile-settings.component';
1616
import { DownloadComponent } from '@shared/download/download.component';
1717
import { NavigationGuardService } from './logbook/core/navigation-guard-service';
18+
import { AuthCallbackComponent } from './auth-callback/auth-callback.component';
1819

1920
const routes: Routes = [
2021
{ path: '', redirectTo: '/login', pathMatch: 'full' },
2122
{ path: 'login', component: LoginComponent },
23+
{ path: 'auth-callback', component: AuthCallbackComponent },
2224
{ path: 'overview', component: OverviewComponent },
2325
{ path: 'download/:fileId', component: DownloadComponent },
2426
{ path: 'logbooks/:logbookId', component: LogbookComponent,

scilog/src/app/app.module.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ import { MatPaginatorModule } from '@angular/material/paginator';
8888
import { MatSortModule } from '@angular/material/sort';
8989
import { OverviewScrollComponent } from './overview/overview-scroll/overview-scroll.component';
9090
import { ActionsMenuComponent } from './overview/actions-menu/actions-menu.component';
91+
import { ScicatViewerComponent } from './logbook/widgets/scicat-viewer/scicat-viewer.component';
92+
import { AuthCallbackComponent } from './auth-callback/auth-callback.component';
9193

9294
const appConfigInitializerFn = (appConfig: AppConfigService) => {
9395
return () => appConfig.loadAppConfig();
@@ -135,7 +137,9 @@ const appConfigInitializerFn = (appConfig: AppConfigService) => {
135137
ResizedDirective,
136138
OverviewTableComponent,
137139
OverviewScrollComponent,
138-
ActionsMenuComponent
140+
ActionsMenuComponent,
141+
ScicatViewerComponent,
142+
AuthCallbackComponent
139143
],
140144
imports: [
141145
BrowserModule,

scilog/src/app/auth-callback/auth-callback.component.css

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>auth-callback works!</p>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { AuthCallbackComponent } from './auth-callback.component';
3+
import { provideRouter, Router } from '@angular/router';
4+
import { Component } from '@angular/core';
5+
6+
@Component({})
7+
class DummyComponent {}
8+
9+
describe('AuthCallbackComponent', () => {
10+
let component: AuthCallbackComponent;
11+
let fixture: ComponentFixture<AuthCallbackComponent>;
12+
let router: Router;
13+
14+
beforeEach(async () => {
15+
await TestBed.configureTestingModule({
16+
declarations: [AuthCallbackComponent],
17+
providers: [
18+
provideRouter([
19+
{ path: 'overview', component: DummyComponent },
20+
{ path: 'auth-callback', component: AuthCallbackComponent },
21+
{ path: 'dashboard', component: DummyComponent },
22+
]),
23+
],
24+
}).compileComponents();
25+
26+
fixture = TestBed.createComponent(AuthCallbackComponent);
27+
component = fixture.componentInstance;
28+
router = TestBed.inject(Router);
29+
});
30+
31+
it('should create', () => {
32+
expect(component).toBeTruthy();
33+
});
34+
35+
it('should set scicat token and redirect to returnUrl', async () => {
36+
const routerSpy = spyOn(router, 'navigateByUrl').and.callThrough();
37+
await router.navigateByUrl(
38+
'/auth-callback?access-token=123&returnUrl=/dashboard'
39+
);
40+
fixture.detectChanges();
41+
expect(localStorage.getItem('scicat_token')).toEqual('123');
42+
expect(routerSpy).toHaveBeenCalledWith('/dashboard');
43+
localStorage.removeItem('scicat_token');
44+
});
45+
46+
it('should should redirect to overview if no returnUrl', async () => {
47+
const routerSpy = spyOn(router, 'navigate').and.callThrough();
48+
await router.navigateByUrl('/auth-callback?access-token=123');
49+
fixture.detectChanges();
50+
expect(routerSpy).toHaveBeenCalledWith(['/overview']);
51+
});
52+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Component, OnDestroy, OnInit } from '@angular/core';
2+
import { ActivatedRoute, Router } from '@angular/router';
3+
import { Subscription } from 'rxjs';
4+
5+
@Component({
6+
selector: 'app-auth-callback',
7+
template: '',
8+
styles: [],
9+
})
10+
export class AuthCallbackComponent implements OnInit, OnDestroy {
11+
constructor(private route: ActivatedRoute, private router: Router) {}
12+
13+
subscriptions: Subscription[] = [];
14+
15+
ngOnInit(): void {
16+
const sub = this.route.queryParams.subscribe((params) => {
17+
const token = params['access-token'];
18+
const returnUrl = params['returnUrl'];
19+
20+
if (token) {
21+
localStorage.setItem('scicat_token', token);
22+
}
23+
24+
if (returnUrl) {
25+
this.router.navigateByUrl(returnUrl);
26+
} else {
27+
this.router.navigate(['/overview']);
28+
}
29+
});
30+
this.subscriptions.push(sub);
31+
}
32+
33+
ngOnDestroy(): void {
34+
this.subscriptions.forEach((sub) => sub.unsubscribe());
35+
}
36+
}
Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,63 @@
11
import { TestBed } from '@angular/core/testing';
22

33
import { AuthInterceptor } from './auth.interceptor';
4+
import { ServerSettingsService } from '@shared/config/server-settings.service';
5+
import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http';
6+
import { of } from 'rxjs';
47

58
describe('AuthInterceptor', () => {
6-
beforeEach(() => TestBed.configureTestingModule({
7-
providers: [
8-
AuthInterceptor
9-
]
10-
}));
9+
const serverSettingsService = jasmine.createSpyObj('ServerSettingsService', [
10+
'getSciCatServerAddress',
11+
]);
12+
serverSettingsService.getSciCatServerAddress.and.returnValue(
13+
'https://scicat-backend.psi.ch'
14+
);
15+
beforeEach(() =>
16+
TestBed.configureTestingModule({
17+
providers: [
18+
AuthInterceptor,
19+
{ provide: ServerSettingsService, useValue: serverSettingsService },
20+
],
21+
})
22+
);
1123

1224
it('should be created', () => {
1325
const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor);
1426
expect(interceptor).toBeTruthy();
1527
});
28+
29+
it('should append scicat token if request to scicat backend', (done: DoneFn) => {
30+
localStorage.setItem('scicat_token', 'test_scicat_token');
31+
const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor);
32+
const req = new HttpRequest("GET", 'https://scicat-backend.psi.ch/api/v3/datasets');
33+
const next = jasmine.createSpyObj<HttpHandler>('HttpHandler', ['handle']);
34+
next.handle.and.returnValue(of({} as HttpEvent<any>));
35+
interceptor.intercept(req, next).subscribe({
36+
next: (_httpEvent: HttpEvent<any>) => {
37+
const reqArg = next.handle.calls.mostRecent().args[0];
38+
expect(reqArg.headers.get('Authorization')).toBe('Bearer test_scicat_token');
39+
localStorage.clear();
40+
done();
41+
},
42+
error: done.fail,
43+
});
44+
});
45+
46+
it('should append scilog token if request is not to scicat backend', (done: DoneFn) => {
47+
localStorage.setItem('id_token', 'test_scilog_token');
48+
const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor);
49+
const req = new HttpRequest("GET", 'https://scilog-backend.psi.ch/api/v1');
50+
const next = jasmine.createSpyObj<HttpHandler>('HttpHandler', ['handle']);
51+
next.handle.and.returnValue(of({} as HttpEvent<any>));
52+
interceptor.intercept(req, next).subscribe({
53+
next: (_httpEvent: HttpEvent<any>) => {
54+
const reqArg = next.handle.calls.mostRecent().args[0];
55+
expect(reqArg.headers.get('Authorization')).toBe('Bearer test_scilog_token');
56+
localStorage.clear();
57+
done();
58+
},
59+
error: done.fail,
60+
});
61+
});
1662
});
63+
Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,84 @@
1-
import { Injectable } from '@angular/core';
1+
import { inject, Injectable } from '@angular/core';
22
import {
33
HttpRequest,
44
HttpHandler,
55
HttpEvent,
66
HttpInterceptor,
77
HttpResponse,
8-
HttpErrorResponse
8+
HttpErrorResponse,
99
} from '@angular/common/http';
1010
import { Observable } from 'rxjs';
1111
import { tap } from 'rxjs/operators';
12+
import { ServerSettingsService } from '@shared/config/server-settings.service';
1213

1314
function logout() {
14-
localStorage.removeItem("id_token");
15+
localStorage.removeItem('id_token');
1516
localStorage.removeItem('id_session');
17+
localStorage.removeItem('scicat_token');
1618
sessionStorage.removeItem('scilog-auto-selection-logbook');
1719
location.href = '/login';
18-
};
19-
20-
function handle_request(handler: HttpHandler, req: HttpRequest<any>) {
21-
return handler.handle(req).pipe(tap((event: HttpEvent<any>) => {
22-
if (event instanceof HttpResponse) {
23-
// console.log(cloned);
24-
// console.log("Service Response thr Interceptor");
25-
}
26-
}, (err: any) => {
27-
if (err instanceof HttpErrorResponse) {
28-
console.log("err.status", err);
29-
if (err.status === 401) {
30-
logout();
31-
}
32-
}
33-
}));
3420
}
3521

3622
@Injectable()
3723
export class AuthInterceptor implements HttpInterceptor {
24+
private serverSettingsService = inject(ServerSettingsService);
3825

39-
intercept(req: HttpRequest<any>,
40-
next: HttpHandler): Observable<HttpEvent<any>> {
26+
private isRequestToSciCatBackend(req_url: string): boolean {
27+
try {
28+
const origin = new URL(req_url).origin;
29+
return (
30+
origin ===
31+
new URL(this.serverSettingsService.getSciCatServerAddress()).origin
32+
);
33+
} catch (err) {
34+
// new URL(...) fails for request to static assets (e.g. /assets/config.json)
35+
return false;
36+
}
37+
}
4138

42-
const idToken = localStorage.getItem("id_token");
39+
private handle_request(handler: HttpHandler, req: HttpRequest<any>) {
40+
return handler.handle(req).pipe(
41+
tap({
42+
next: (event: HttpEvent<any>) => {
43+
if (event instanceof HttpResponse) {
44+
// console.log(cloned);
45+
// console.log("Service Response thr Interceptor");
46+
}
47+
},
48+
error: (err: any) => {
49+
if (err instanceof HttpErrorResponse) {
50+
console.log('err.status', err);
51+
if (err.status === 401) {
52+
if (!this.isRequestToSciCatBackend(err.url)) {
53+
logout();
54+
} else {
55+
const returnURL = window.location.pathname + window.location.search;
56+
window.location.href = this.serverSettingsService.getScicatLoginUrl(returnURL);
57+
}
58+
}
59+
}
60+
},
61+
})
62+
);
63+
}
4364

65+
intercept(
66+
req: HttpRequest<any>,
67+
next: HttpHandler
68+
): Observable<HttpEvent<any>> {
69+
let idToken = '';
70+
if (this.isRequestToSciCatBackend(req.url)) {
71+
idToken = localStorage.getItem('scicat_token');
72+
} else {
73+
idToken = localStorage.getItem('id_token');
74+
}
4475
if (idToken) {
4576
const cloned = req.clone({
46-
headers: req.headers.set("Authorization",
47-
"Bearer " + idToken)
77+
headers: req.headers.set('Authorization', 'Bearer ' + idToken),
4878
});
49-
50-
return handle_request(next, cloned);
51-
52-
}
53-
else {
54-
return handle_request(next, req);
79+
return this.handle_request(next, cloned);
80+
} else {
81+
return this.handle_request(next, req);
5582
}
5683
}
5784
}

scilog/src/app/core/auth-services/auth.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class AuthService {
3131
logout() {
3232
localStorage.removeItem("id_token");
3333
localStorage.removeItem('id_session');
34+
localStorage.removeItem('scicat_token');
3435
sessionStorage.removeItem('scilog-auto-selection-logbook');
3536
this.forceReload=true;
3637
}

0 commit comments

Comments
 (0)