feat: add auth support (default and paia)

This commit is contained in:
Michel Jonathan Schmitz
2022-01-24 18:43:00 +00:00
committed by Jovan Krunić
parent 046a95ba1d
commit b5f239ea4e
85 changed files with 3626 additions and 119 deletions

View File

@@ -0,0 +1,3 @@
<div class="centeredMessageContainer">
<p>{{ 'auth.messages.default.authorizing' | translate }}</p>
</div>

View File

@@ -0,0 +1,34 @@
import {Component, OnInit, OnDestroy} from '@angular/core';
import {NavController} from '@ionic/angular';
import {Router} from '@angular/router';
import {IAuthAction} from 'ionic-appauth';
import {Subscription} from 'rxjs';
import {DefaultAuthService} from '../../default-auth.service';
@Component({
selector: 'auth-callback',
templateUrl: './auth-callback-page.component.html',
styleUrls: ['./auth-callback-page.component.scss'],
})
export class AuthCallbackPageComponent implements OnInit, OnDestroy {
sub: Subscription;
constructor(
private auth: DefaultAuthService,
private navCtrl: NavController,
private router: Router,
) {}
ngOnInit() {
this.sub = this.auth.events$.subscribe(action => this.postCallback(action));
this.auth.authorizationCallback(window.location.origin + this.router.url);
}
ngOnDestroy() {
this.sub.unsubscribe();
}
async postCallback(_action: IAuthAction) {
await this.navCtrl.navigateRoot('profile');
}
}

View File

@@ -0,0 +1,42 @@
import {Injectable} from '@angular/core';
import {CanActivate, Router, RouterStateSnapshot} from '@angular/router';
import {DefaultAuthService} from './default-auth.service';
import {PAIAAuthService} from './paia/paia-auth.service';
import {IAuthService} from 'ionic-appauth';
import {ActivatedAuthRouteSnapshot} from './auth-routes';
@Injectable({
providedIn: 'root',
})
export class AuthGuardService implements CanActivate {
authService: IAuthService | PAIAAuthService;
constructor(
private defaultAuth: DefaultAuthService,
private paiaAuth: PAIAAuthService,
private router: Router,
) {}
public async canActivate(
route: ActivatedAuthRouteSnapshot,
_state: RouterStateSnapshot,
) {
switch (route.data.authProvider) {
case 'paia':
this.authService = this.paiaAuth;
break;
default:
this.authService = this.defaultAuth;
break;
}
try {
await this.authService.getValidToken();
} catch {
this.router.navigate(['profile']);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,50 @@
import {Injectable} from '@angular/core';
import {
SCAuthorizationProviderType,
SCUserConfiguration,
userMapping,
} from '../profile/user';
import {IPAIAAuthAction} from './paia/paia-auth-action';
import {AuthActions, IAuthAction} from 'ionic-appauth';
import {TranslateService} from '@ngx-translate/core';
import {JSONFile} from '@angular/cli/utilities/json-file';
import {JSONPath} from 'jsonpath-plus';
@Injectable({
providedIn: 'root',
})
export class AuthHelperService {
constructor(private translateService: TranslateService) {}
public getAuthMessage(
provider: SCAuthorizationProviderType,
action: IAuthAction | IPAIAAuthAction,
) {
let message: string | undefined;
switch (action.action) {
case AuthActions.SignInSuccess:
message = this.translateService.instant(
`auth.messages.${provider}.logged_in_success`,
);
break;
case AuthActions.SignOutSuccess:
message = this.translateService.instant(
`auth.messages.${provider}.logged_out_success`,
);
break;
}
return message;
}
getUserFromUserInfo(userInfo: JSONFile) {
const user: SCUserConfiguration = {id: '', name: '', role: 'student'};
for (const key in userMapping) {
user[key as keyof SCUserConfiguration] = JSONPath({
path: userMapping[key as keyof SCUserConfiguration] as string,
json: userInfo,
})[0];
}
return user;
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ActivatedRouteSnapshot, Data, Route} from '@angular/router';
import {SCAuthorizationProviderType} from '../profile/user';
export interface AuthRoute extends Route {
data: {
authProvider: SCAuthorizationProviderType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
};
}
export class ActivatedAuthRouteSnapshot extends ActivatedRouteSnapshot {
data: Data & {authProvider: AuthRoute['data']['authProvider']};
}
export type AuthRoutes = AuthRoute[];

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {RouterModule, Routes} from '@angular/router';
import {NgModule} from '@angular/core';
import {AuthCallbackPageComponent} from './auth-callback/page/auth-callback-page.component';
import {PAIAAuthCallbackPageComponent} from './paia/auth-callback/page/auth-callback-page.component';
const authRoutes: Routes = [
{path: 'auth/callback', component: AuthCallbackPageComponent},
{path: 'auth/paia/callback', component: PAIAAuthCallbackPageComponent},
];
/**
* Module defining routes for auth module
*/
@NgModule({
exports: [RouterModule],
imports: [RouterModule.forChild(authRoutes)],
})
export class AuthRoutingModule {}

View File

@@ -0,0 +1,47 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Platform} from '@ionic/angular';
import {Requestor, StorageBackend} from '@openid/appauth';
import {authFactory, paiaAuthFactory, storageFactory} from './factories';
import {DefaultAuthService} from './default-auth.service';
import {Browser} from 'ionic-appauth';
import {CapacitorBrowser} from 'ionic-appauth/lib/capacitor';
import {httpFactory} from './factories/http.factory';
import {HttpClient} from '@angular/common/http';
import {PAIAAuthService} from './paia/paia-auth.service';
import {AuthRoutingModule} from './auth-routing.module';
import {TranslateModule} from '@ngx-translate/core';
import {AuthCallbackPageComponent} from './auth-callback/page/auth-callback-page.component';
import {PAIAAuthCallbackPageComponent} from './paia/auth-callback/page/auth-callback-page.component';
@NgModule({
declarations: [AuthCallbackPageComponent, PAIAAuthCallbackPageComponent],
imports: [CommonModule, AuthRoutingModule, TranslateModule],
providers: [
{
provide: StorageBackend,
useFactory: storageFactory,
deps: [Platform],
},
{
provide: Requestor,
useFactory: httpFactory,
deps: [Platform, HttpClient],
},
{
provide: Browser,
useClass: CapacitorBrowser,
},
{
provide: DefaultAuthService,
useFactory: authFactory,
deps: [Requestor, Browser, StorageBackend],
},
{
provide: PAIAAuthService,
useFactory: paiaAuthFactory,
deps: [Requestor, Browser, StorageBackend],
},
],
})
export class AuthModule {}

View File

@@ -0,0 +1,53 @@
import {Requestor} from '@openid/appauth';
import {Http, HttpHeaders, HttpResponse} from '@capacitor-community/http';
import {XhrSettings} from 'ionic-appauth/lib/cordova';
import qs from 'qs';
// REQUIRES CAPACITOR PLUGIN
// @capacitor-community/http
export class CapacitorRequestor extends Requestor {
constructor() {
super();
}
public async xhr<T>(settings: XhrSettings): Promise<T> {
if (!settings.method) settings.method = 'GET';
switch (settings.method) {
case 'GET':
return this.get(settings.url, settings.headers);
case 'POST':
return this.post(settings.url, settings.data, settings.headers);
case 'PUT':
return this.put(settings.url, settings.data, settings.headers);
case 'DELETE':
return this.delete(settings.url, settings.headers);
}
}
private async get<T>(url: string, headers: HttpHeaders) {
return Http.get({url, headers}).then(
(response: HttpResponse) => response.data as T,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async post<T>(url: string, data: any, headers: HttpHeaders) {
return Http.post({url, data: qs.parse(data), headers}).then(
(response: HttpResponse) => response.data as T,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async put<T>(url: string, data: any, headers: HttpHeaders) {
return Http.put({url, data, headers}).then(
(response: HttpResponse) => response.data as T,
);
}
private async delete<T>(url: string, headers: HttpHeaders) {
return Http.del({url, headers}).then(
(response: HttpResponse) => response.data as T,
);
}
}

View File

@@ -0,0 +1,53 @@
import {AuthorizationRequestHandler} from '@openid/appauth';
import {
StorageBackend,
Requestor,
AuthorizationServiceConfiguration,
LocalStorageBackend,
JQueryRequestor,
TokenRequestHandler,
} from '@openid/appauth';
import {
UserInfoHandler,
EndSessionHandler,
Browser,
DefaultBrowser,
AuthService,
AuthActionBuilder,
} from 'ionic-appauth';
const TOKEN_RESPONSE_KEY = 'token_response';
export class DefaultAuthService extends AuthService {
public localConfiguration: AuthorizationServiceConfiguration;
protected tokenHandler: TokenRequestHandler;
protected userInfoHandler: UserInfoHandler;
protected requestHandler: AuthorizationRequestHandler;
protected endSessionHandler: EndSessionHandler;
constructor(
protected browser: Browser = new DefaultBrowser(),
protected storage: StorageBackend = new LocalStorageBackend(),
protected requestor: Requestor = new JQueryRequestor(),
) {
super(browser, storage, requestor);
}
get configuration(): Promise<AuthorizationServiceConfiguration> {
if (!this.localConfiguration)
throw new Error('Local Configuration Not Defined');
return Promise.resolve(this.localConfiguration);
}
public async signOut() {
await this.storage.removeItem(TOKEN_RESPONSE_KEY).catch(error => {
this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));
});
this.notifyActionListers(AuthActionBuilder.SignOutSuccess());
}
}

View File

@@ -0,0 +1,26 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {Routes, RouterModule} from '@angular/router';
import {IonicModule} from '@ionic/angular';
import {EndSessionPageComponent} from './page/end-session-page.component';
const routes: Routes = [
{
path: 'logout',
component: EndSessionPageComponent,
},
];
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
],
declarations: [EndSessionPageComponent],
})
export class EndSessionPageModule {}

View File

@@ -0,0 +1 @@
<p>Signing out...</p>

View File

@@ -0,0 +1,20 @@
import {Component, OnInit} from '@angular/core';
import {NavController} from '@ionic/angular';
import {DefaultAuthService} from '../../default-auth.service';
@Component({
selector: 'end-session',
templateUrl: './end-session-page.component.html',
styleUrls: ['./end-session-page.component.scss'],
})
export class EndSessionPageComponent implements OnInit {
constructor(
private auth: DefaultAuthService,
private navCtrl: NavController,
) {}
async ngOnInit() {
this.auth.endSessionCallback();
await this.navCtrl.navigateRoot('profile');
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
StorageBackend,
Requestor,
AuthorizationServiceConfiguration,
} from '@openid/appauth';
import {Browser} from 'ionic-appauth';
import {environment} from 'src/environments/environment';
import {DefaultAuthService} from '../default-auth.service';
import {PAIAAuthService} from '../paia/paia-auth.service';
export const authFactory = (
requestor: Requestor,
browser: Browser,
storage: StorageBackend,
) => {
const authService = new DefaultAuthService(browser, storage, requestor);
authService.authConfig = environment.oauth2.client.his;
authService.localConfiguration = new AuthorizationServiceConfiguration(
environment.oauth2.service.his,
);
return authService;
};
export const paiaAuthFactory = (
requestor: Requestor,
browser: Browser,
storage: StorageBackend,
) => {
const authService = new PAIAAuthService(browser, storage, requestor);
authService.authConfig = environment.oauth2.client.paia;
authService.localConfiguration = new AuthorizationServiceConfiguration(
environment.oauth2.service.paia,
);
return authService;
};

View File

@@ -0,0 +1,9 @@
import {Platform} from '@ionic/angular';
import {DefaultBrowser} from 'ionic-appauth';
import {CapacitorBrowser} from 'ionic-appauth/lib/capacitor';
export const browserFactory = (platform: Platform) => {
return platform.is('capacitor')
? new CapacitorBrowser()
: new DefaultBrowser();
};

View File

@@ -0,0 +1,10 @@
import {HttpClient} from '@angular/common/http';
import {Platform} from '@ionic/angular';
import {CapacitorRequestor} from '../capacitor-requestor';
import {NgHttpService} from '../ng-http.service';
export const httpFactory = (platform: Platform, httpClient: HttpClient) => {
return platform.is('capacitor')
? new CapacitorRequestor()
: new NgHttpService(httpClient);
};

View File

@@ -0,0 +1,3 @@
export * from './auth.factory';
export * from './browser.factory';
export * from './storage.factory';

View File

@@ -0,0 +1,9 @@
import {Platform} from '@ionic/angular';
import {CapacitorSecureStorage} from 'ionic-appauth/lib/capacitor';
import {IonicStorage} from 'ionic-appauth/lib';
export const storageFactory = (platform: Platform) => {
return platform.is('capacitor')
? new CapacitorSecureStorage()
: new IonicStorage();
};

View File

@@ -0,0 +1,53 @@
import {Injectable} from '@angular/core';
import {Requestor} from '@openid/appauth';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {XhrSettings} from 'ionic-appauth/lib/cordova';
@Injectable({
providedIn: 'root',
})
export class NgHttpService implements Requestor {
constructor(private http: HttpClient) {}
public async xhr<T>(settings: XhrSettings): Promise<T> {
if (!settings.method) {
settings.method = 'GET';
}
switch (settings.method) {
case 'GET':
return this.http
.get<T>(settings.url, {headers: this.getHeaders(settings.headers)})
.toPromise();
case 'POST':
return this.http
.post<T>(settings.url, settings.data, {
headers: this.getHeaders(settings.headers),
})
.toPromise();
case 'PUT':
return this.http
.put<T>(settings.url, settings.data, {
headers: this.getHeaders(settings.headers),
})
.toPromise();
case 'DELETE':
return this.http
.delete<T>(settings.url, {headers: this.getHeaders(settings.headers)})
.toPromise();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private getHeaders(headers: any): HttpHeaders {
let httpHeaders: HttpHeaders = new HttpHeaders();
if (headers !== undefined) {
for (const key of Object.keys(headers)) {
httpHeaders = httpHeaders.append(key, headers[key]);
}
}
return httpHeaders;
}
}

View File

@@ -0,0 +1,3 @@
<div class="centeredMessageContainer">
<p>{{ 'auth.messages.paia.authorizing' | translate }}</p>
</div>

View File

@@ -0,0 +1,34 @@
import {Component, OnInit, OnDestroy} from '@angular/core';
import {NavController} from '@ionic/angular';
import {Router} from '@angular/router';
import {IAuthAction} from 'ionic-appauth';
import {Subscription} from 'rxjs';
import {PAIAAuthService} from '../../paia-auth.service';
@Component({
selector: 'auth-callback',
templateUrl: './auth-callback-page.component.html',
styleUrls: ['./auth-callback-page.component.scss'],
})
export class PAIAAuthCallbackPageComponent implements OnInit, OnDestroy {
sub: Subscription;
constructor(
private auth: PAIAAuthService,
private navCtrl: NavController,
private router: Router,
) {}
ngOnInit() {
this.sub = this.auth.events$.subscribe(action => this.postCallback(action));
this.auth.authorizationCallback(window.location.origin + this.router.url);
}
ngOnDestroy() {
this.sub.unsubscribe();
}
async postCallback(_action: IAuthAction) {
this.navCtrl.navigateRoot('profile');
}
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
StorageBackend,
BasicQueryStringUtils,
DefaultCrypto,
AuthorizationServiceConfiguration,
AuthorizationRequest,
StringMap,
AuthorizationError,
AuthorizationErrorJson,
} from '@openid/appauth';
import {Browser} from 'ionic-appauth';
import {PAIAAuthorizationNotifier} from './paia-authorization-notifier';
import {PAIAAuthorizationRequestResponse} from './authorization-request-response';
import {
PAIAAuthorizationResponse,
PAIAAuthorizationResponseJson,
} from './paia-authorization-response';
/** key for authorization request. */
const authorizationRequestKey = (handle: string) => {
return `${handle}_appauth_authorization_request`;
};
/** key in local storage which represents the current authorization request. */
const AUTHORIZATION_REQUEST_HANDLE_KEY =
'appauth_current_authorization_request';
export const AUTHORIZATION_RESPONSE_KEY = 'auth_response';
// TODO: PAIA specific ...!!! use whatever you can from the parent class !
export class PAIAAuthorizationRequestHandler {
notifier: PAIAAuthorizationNotifier;
constructor(
private browser: Browser,
private storage: StorageBackend,
public utils = new BasicQueryStringUtils(),
protected crypto = new Crypto(),
private generateRandom = new DefaultCrypto(),
) {}
public async performAuthorizationRequest(
configuration: AuthorizationServiceConfiguration,
request: AuthorizationRequest,
): Promise<void> {
const handle = this.generateRandom.generateRandom(10);
await this.storage.setItem(AUTHORIZATION_REQUEST_HANDLE_KEY, handle);
await this.storage.setItem(
authorizationRequestKey(handle),
JSON.stringify(await request.toJson()),
);
const url = this.buildRequestUrl(configuration, request);
const returnedUrl: string | undefined = await this.browser.showWindow(
url,
request.redirectUri,
);
// callback may come from showWindow or via another method
if (typeof returnedUrl !== 'undefined') {
await this.storage.setItem(AUTHORIZATION_RESPONSE_KEY, returnedUrl);
await this.completeAuthorizationRequestIfPossible();
}
}
protected async completeAuthorizationRequest(): Promise<PAIAAuthorizationRequestResponse> {
const handle = await this.storage.getItem(AUTHORIZATION_REQUEST_HANDLE_KEY);
if (!handle) {
throw new Error('Handle Not Available');
}
const request: AuthorizationRequest = this.getAuthorizationRequest(
await this.storage.getItem(authorizationRequestKey(handle)),
);
const queryParameters = this.getQueryParams(
await this.storage.getItem(AUTHORIZATION_RESPONSE_KEY),
);
void this.removeItemsFromStorage(handle);
// const state: string | undefined = queryParams['state'];
const error: string | undefined = queryParameters['error'];
// TODO: we need state from PAIA (we don't get state at the moment)
// if (state !== request.state) {
// throw new Error("State Does Not Match");
// }
return <PAIAAuthorizationRequestResponse>{
request: request, // request
response: !error
? this.getAuthorizationResponse(queryParameters)
: undefined,
error: error ? this.getAuthorizationError(queryParameters) : undefined,
};
}
private getAuthorizationRequest(
authRequest: string | null,
): AuthorizationRequest {
if (authRequest == undefined) {
throw new Error('No Auth Request Available');
}
return new AuthorizationRequest(JSON.parse(authRequest));
}
private getAuthorizationError(
queryParameters: StringMap,
): AuthorizationError {
const authorizationErrorJSON: AuthorizationErrorJson = {
error: queryParameters['error'],
error_description: queryParameters['error_description'],
error_uri: undefined,
state: queryParameters['state'],
};
return new AuthorizationError(authorizationErrorJSON);
}
private getAuthorizationResponse(
queryParameters: StringMap,
): PAIAAuthorizationResponse {
const authorizationResponseJSON: PAIAAuthorizationResponseJson = {
code: queryParameters['code'],
patron: queryParameters['patron'],
// TODO: currently PAIA is not providing state
state: queryParameters['state'] ?? '',
};
return new PAIAAuthorizationResponse(authorizationResponseJSON);
}
private async removeItemsFromStorage(handle: string): Promise<void> {
await this.storage.removeItem(AUTHORIZATION_REQUEST_HANDLE_KEY);
await this.storage.removeItem(authorizationRequestKey(handle));
await this.storage.removeItem(AUTHORIZATION_RESPONSE_KEY);
}
private getQueryParams(authResponse: string | null): StringMap {
if (authResponse != undefined) {
const querySide: string = authResponse.split('#')[0];
const parts: string[] = querySide.split('?');
if (parts.length !== 2) throw new Error('Invalid auth response string');
const hash = parts[1];
return this.utils.parseQueryString(hash);
} else {
return {};
}
}
setAuthorizationNotifier(
notifier: PAIAAuthorizationNotifier,
): PAIAAuthorizationRequestHandler {
this.notifier = notifier;
return this;
}
completeAuthorizationRequestIfPossible(): Promise<void> {
// call complete authorization if possible to see there might
// be a response that needs to be delivered.
console.log(
`Checking to see if there is an authorization response to be delivered.`,
);
if (!this.notifier) {
console.log(`Notifier is not present on AuthorizationRequest handler.
No delivery of result will be possible`);
}
return this.completeAuthorizationRequest().then(result => {
if (!result) {
console.log(`No result is available yet.`);
}
if (result && this.notifier) {
this.notifier.onAuthorizationComplete(
result.request,
result.response,
result.error,
);
}
});
}
/**
* A utility method to be able to build the authorization request URL.
*/
protected buildRequestUrl(
configuration: AuthorizationServiceConfiguration,
request: AuthorizationRequest,
) {
// build the query string
// coerce to any type for convenience
const requestMap: StringMap = {
redirect_uri: request.redirectUri,
client_id: request.clientId,
response_type: request.responseType,
state: request.state,
scope: request.scope,
};
const query = this.utils.stringify(requestMap);
const baseUrl = configuration.authorizationEndpoint;
return `${baseUrl}?${query}&grant_type=client_credentials`;
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AuthorizationError, AuthorizationRequest} from '@openid/appauth';
import {PAIAAuthorizationResponse} from './paia-authorization-response';
/**
* Represents a structural type holding both authorization request and response.
*/
export interface PAIAAuthorizationRequestResponse {
request: AuthorizationRequest;
response: PAIAAuthorizationResponse | null;
error: AuthorizationError | null;
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {PAIATokenResponse} from './paia-token-response';
import {AuthActionBuilder, IAuthAction} from 'ionic-appauth';
export interface IPAIAAuthAction extends IAuthAction {
tokenResponse?: PAIATokenResponse;
}
export class PAIAAuthActionBuilder extends AuthActionBuilder {
public static Init(): IPAIAAuthAction {
return AuthActionBuilder.Init() as IPAIAAuthAction;
}
public static SignOutSuccess(): IPAIAAuthAction {
return AuthActionBuilder.SignOutSuccess() as IPAIAAuthAction;
}
public static SignOutFailed(error: Error): IPAIAAuthAction {
return AuthActionBuilder.SignOutFailed(error) as IPAIAAuthAction;
}
public static RefreshSuccess(
tokenResponse: PAIATokenResponse,
): IPAIAAuthAction {
return AuthActionBuilder.RefreshSuccess(tokenResponse) as IPAIAAuthAction;
}
public static RefreshFailed(error: Error): IPAIAAuthAction {
return AuthActionBuilder.RefreshFailed(error) as IPAIAAuthAction;
}
public static SignInSuccess(
tokenResponse: PAIATokenResponse,
): IPAIAAuthAction {
return AuthActionBuilder.SignInSuccess(tokenResponse) as IPAIAAuthAction;
}
public static SignInFailed(error: Error): IPAIAAuthAction {
return AuthActionBuilder.SignInFailed(error) as IPAIAAuthAction;
}
public static LoadTokenFromStorageSuccess(
tokenResponse: PAIATokenResponse,
): IPAIAAuthAction {
return AuthActionBuilder.LoadTokenFromStorageSuccess(
tokenResponse,
) as IPAIAAuthAction;
}
public static LoadTokenFromStorageFailed(error: Error): IPAIAAuthAction {
return AuthActionBuilder.LoadTokenFromStorageFailed(
error,
) as IPAIAAuthAction;
}
public static RevokeTokensSuccess(): IPAIAAuthAction {
return AuthActionBuilder.RevokeTokensSuccess() as IPAIAAuthAction;
}
public static RevokeTokensFailed(error: Error): IPAIAAuthAction {
return AuthActionBuilder.RevokeTokensFailed(error) as IPAIAAuthAction;
}
public static LoadUserInfoSuccess(user: Error): IPAIAAuthAction {
return AuthActionBuilder.LoadUserInfoSuccess(user) as IPAIAAuthAction;
}
public static LoadUserInfoFailed(error: Error): IPAIAAuthAction {
return AuthActionBuilder.LoadUserInfoFailed(error) as IPAIAAuthAction;
}
}

View File

@@ -0,0 +1,342 @@
import {
AuthorizationError,
AuthorizationRequest,
AuthorizationRequestJson,
AuthorizationServiceConfiguration,
BasicQueryStringUtils,
DefaultCrypto,
JQueryRequestor,
LocalStorageBackend,
Requestor,
StorageBackend,
StringMap,
} from '@openid/appauth';
import {
AuthActions,
AUTHORIZATION_RESPONSE_KEY,
AuthSubject,
Browser,
DefaultBrowser,
EndSessionHandler,
IAuthConfig,
IonicEndSessionHandler,
IonicUserInfoHandler,
UserInfoHandler,
} from 'ionic-appauth';
import {BehaviorSubject, Observable} from 'rxjs';
import {PAIATokenRequestHandler} from './token-request-handler';
import {PAIAAuthorizationRequestHandler} from './authorization-request-handler';
import {PAIATokenRequest, PAIATokenRequestJson} from './paia-token-request';
import {PAIAAuthorizationResponse} from './paia-authorization-response';
import {PAIAAuthorizationNotifier} from './paia-authorization-notifier';
import {PAIATokenResponse} from './paia-token-response';
import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action';
const TOKEN_KEY = 'auth_paia_token';
const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds
export interface IAuthService {
signIn(authExtras?: StringMap, state?: string): void;
signOut(state?: string, revokeTokens?: boolean): void;
loadUserInfo(): void;
authorizationCallback(callbackUrl: string): void;
loadTokenFromStorage(): void;
getValidToken(buffer?: number): Promise<PAIATokenResponse>;
}
export class PAIAAuthService implements IAuthService {
private _authConfig?: IAuthConfig;
private _authSubject: AuthSubject = new AuthSubject();
private _authSubjectV2 = new BehaviorSubject<IPAIAAuthAction>(
PAIAAuthActionBuilder.Init(),
);
private _tokenSubject = new BehaviorSubject<PAIATokenResponse | undefined>(
undefined,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _userSubject = new BehaviorSubject<any>(undefined);
private _authenticatedSubject = new BehaviorSubject<boolean>(false);
private _initComplete = new BehaviorSubject<boolean>(false);
protected tokenHandler: PAIATokenRequestHandler;
protected userInfoHandler: UserInfoHandler;
protected requestHandler: PAIAAuthorizationRequestHandler;
protected endSessionHandler: EndSessionHandler;
public localConfiguration: AuthorizationServiceConfiguration;
constructor(
protected browser: Browser = new DefaultBrowser(),
protected storage: StorageBackend = new LocalStorageBackend(),
protected requestor: Requestor = new JQueryRequestor(),
utils = new BasicQueryStringUtils(),
) {
this.tokenHandler = new PAIATokenRequestHandler(requestor);
this.userInfoHandler = new IonicUserInfoHandler(requestor);
this.requestHandler = new PAIAAuthorizationRequestHandler(
browser,
storage,
utils,
crypto,
);
this.endSessionHandler = new IonicEndSessionHandler(browser);
}
get token$(): Observable<PAIATokenResponse | undefined> {
return this._tokenSubject.asObservable();
}
get isAuthenticated$(): Observable<boolean> {
return this._authenticatedSubject.asObservable();
}
get initComplete$(): Observable<boolean> {
return this._initComplete.asObservable();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get user$(): Observable<any> {
return this._userSubject.asObservable();
}
get events$(): Observable<IPAIAAuthAction> {
return this._authSubjectV2.asObservable();
}
get authConfig(): IAuthConfig {
if (!this._authConfig) throw new Error('AuthConfig Not Defined');
return this._authConfig;
}
set authConfig(value: IAuthConfig) {
this._authConfig = value;
}
get configuration(): Promise<AuthorizationServiceConfiguration> {
if (!this.localConfiguration)
throw new Error('Local Configuration Not Defined');
return Promise.resolve(this.localConfiguration);
}
public async init() {
this.setupAuthorizationNotifier();
this.loadTokenFromStorage();
}
protected notifyActionListers(action: IPAIAAuthAction) {
this._authSubjectV2.next(action);
this._authSubject.notify(action);
/* eslint-disable unicorn/no-useless-undefined */
switch (action.action) {
case AuthActions.SignInFailed:
case AuthActions.SignOutSuccess:
case AuthActions.SignOutFailed:
this._tokenSubject.next(undefined);
this._userSubject.next(undefined);
this._authenticatedSubject.next(false);
break;
case AuthActions.LoadTokenFromStorageFailed:
this._tokenSubject.next(undefined);
this._userSubject.next(undefined);
this._authenticatedSubject.next(false);
this._initComplete.next(true);
break;
case AuthActions.SignInSuccess:
this._tokenSubject.next(action.tokenResponse);
this._authenticatedSubject.next(true);
break;
case AuthActions.LoadTokenFromStorageSuccess:
this._tokenSubject.next(action.tokenResponse);
this._authenticatedSubject.next(true);
this._initComplete.next(true);
break;
case AuthActions.RevokeTokensSuccess:
this._tokenSubject.next(undefined);
break;
case AuthActions.LoadUserInfoSuccess:
this._userSubject.next(action.user);
break;
case AuthActions.LoadUserInfoFailed:
this._userSubject.next(undefined);
break;
}
}
protected setupAuthorizationNotifier() {
const notifier = new PAIAAuthorizationNotifier();
this.requestHandler.setAuthorizationNotifier(notifier);
notifier.setAuthorizationListener((request, response, error) =>
this.onAuthorizationNotification(request, response, error),
);
}
protected onAuthorizationNotification(
request: AuthorizationRequest,
response: PAIAAuthorizationResponse | null,
error: AuthorizationError | null,
) {
const codeVerifier: string | undefined =
request.internal != undefined && this.authConfig.pkce
? request.internal.code_verifier
: undefined;
if (response != undefined) {
this.requestAccessToken(response.code, response.patron, codeVerifier);
} else if (error != undefined) {
throw new Error(error.errorDescription);
} else {
throw new Error('Unknown Error With Authentication');
}
}
protected async internalAuthorizationCallback(url: string) {
this.browser.closeWindow();
await this.storage.setItem(AUTHORIZATION_RESPONSE_KEY, url);
return this.requestHandler.completeAuthorizationRequestIfPossible();
}
protected async performAuthorizationRequest(
authExtras?: StringMap,
state?: string,
): Promise<void> {
const requestJson: AuthorizationRequestJson = {
response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
client_id: this.authConfig.client_id,
redirect_uri: this.authConfig.redirect_url,
scope: this.authConfig.scopes,
extras: authExtras,
state: state || undefined,
};
const request = new AuthorizationRequest(
requestJson,
new DefaultCrypto(),
this.authConfig.pkce,
);
if (this.authConfig.pkce) await request.setupCodeVerifier();
return this.requestHandler.performAuthorizationRequest(
await this.configuration,
request,
);
}
protected async requestAccessToken(
code: string,
patron: string,
codeVerifier?: string,
): Promise<void> {
const requestJSON: PAIATokenRequestJson = {
code: code,
patron: patron,
extras: codeVerifier
? {
code_verifier: codeVerifier,
}
: {},
};
const token: PAIATokenResponse =
await this.tokenHandler.performTokenRequest(
await this.configuration,
new PAIATokenRequest(requestJSON),
);
await this.storage.setItem(TOKEN_KEY, JSON.stringify(token.toJson()));
this.notifyActionListers(PAIAAuthActionBuilder.SignInSuccess(token));
}
public async revokeTokens() {
// Note: only locally
await this.storage.removeItem(TOKEN_KEY);
this.notifyActionListers(PAIAAuthActionBuilder.RevokeTokensSuccess());
}
public async signOut() {
await this.revokeTokens().catch(error =>
this.notifyActionListers(PAIAAuthActionBuilder.SignOutFailed(error)),
);
this.notifyActionListers(PAIAAuthActionBuilder.SignOutSuccess());
}
protected async internalLoadTokenFromStorage() {
let token: PAIATokenResponse | undefined;
const tokenResponseString: string | null = await this.storage.getItem(
TOKEN_KEY,
);
if (tokenResponseString != undefined) {
token = new PAIATokenResponse(JSON.parse(tokenResponseString));
if (token) {
return this.notifyActionListers(
PAIAAuthActionBuilder.LoadTokenFromStorageSuccess(token),
);
}
}
throw new Error('No Token In Storage');
}
protected async internalRequestUserInfo() {
if (this._tokenSubject.value) {
const userInfo = await this.userInfoHandler.performUserInfoRequest(
await this.configuration,
this._tokenSubject.value,
);
this.notifyActionListers(
PAIAAuthActionBuilder.LoadUserInfoSuccess(userInfo),
);
} else {
throw new Error('No Token Available');
}
}
public async loadTokenFromStorage() {
await this.internalLoadTokenFromStorage().catch(error => {
this.notifyActionListers(
PAIAAuthActionBuilder.LoadTokenFromStorageFailed(error),
);
});
}
public async signIn(authExtras?: StringMap, state?: string) {
await this.performAuthorizationRequest(authExtras, state).catch(error => {
this.notifyActionListers(PAIAAuthActionBuilder.SignInFailed(error));
});
}
public async loadUserInfo() {
await this.internalRequestUserInfo().catch(error => {
this.notifyActionListers(PAIAAuthActionBuilder.LoadUserInfoFailed(error));
});
}
public authorizationCallback(callbackUrl: string): void {
this.internalAuthorizationCallback(callbackUrl).catch(error => {
this.notifyActionListers(PAIAAuthActionBuilder.SignInFailed(error));
});
}
public async getValidToken(
buffer: number = AUTH_EXPIRY_BUFFER,
): Promise<PAIATokenResponse> {
if (this._tokenSubject.value && this._tokenSubject.value.isValid(buffer)) {
return this._tokenSubject.value;
}
throw new Error('Unable To Obtain Valid Token');
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AuthorizationError, AuthorizationRequest} from '@openid/appauth';
import {PAIAAuthorizationResponse} from './paia-authorization-response';
export type PAIAAuthorizationListener = (
request: AuthorizationRequest,
response: PAIAAuthorizationResponse | null,
error: AuthorizationError | null,
) => void;

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {PAIAAuthorizationListener} from './paia-authorization-listener';
import {AuthorizationError, AuthorizationRequest} from '@openid/appauth';
import {PAIAAuthorizationResponse} from './paia-authorization-response';
export class PAIAAuthorizationNotifier {
// eslint-disable-next-line unicorn/no-null
private listener: PAIAAuthorizationListener | null = null;
setAuthorizationListener(listener: PAIAAuthorizationListener) {
this.listener = listener;
}
/**
* The authorization complete callback.
*/
onAuthorizationComplete(
request: AuthorizationRequest,
response: PAIAAuthorizationResponse | null,
error: AuthorizationError | null,
): void {
if (this.listener) {
// complete authorization request
this.listener(request, response, error);
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface PAIAAuthorizationResponseJson {
code: string;
state: string;
patron: string;
}
export class PAIAAuthorizationResponse {
code: string;
state: string;
patron: string;
constructor(response: PAIAAuthorizationResponseJson) {
this.code = response.code;
this.state = response.state;
this.patron = response.patron;
}
toJson(): PAIAAuthorizationResponseJson {
return {code: this.code, state: this.state, patron: this.patron};
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {StringMap} from '@openid/appauth';
// TODO: add documentation
export interface PAIATokenRequestJson {
code: string;
patron: string;
extras?: StringMap;
}
export class PAIATokenRequest {
code: string;
patron: string;
extras?: StringMap;
constructor(request: PAIATokenRequestJson) {
this.code = request.code;
this.patron = request.patron;
this.extras = request.extras;
}
/**
* Serializes a TokenRequest to a JavaScript object.
*/
toJson(): PAIATokenRequestJson {
return {
code: this.code,
patron: this.patron,
extras: this.extras,
};
}
toStringMap(): StringMap {
const map: StringMap = {
patron: this.patron,
code: this.code,
};
// copy over extras
if (this.extras) {
for (const extra in this.extras) {
if (this.extras.hasOwnProperty(extra) && !map.hasOwnProperty(extra)) {
// check before inserting to requestMap
map[extra] = this.extras[extra];
}
}
}
return map;
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {TokenResponse, TokenResponseJson} from '@openid/appauth';
import {nowInSeconds} from '@openid/appauth';
const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds
export interface PAIATokenResponseJson extends TokenResponseJson {
patron: string;
}
export class PAIATokenResponse extends TokenResponse {
patron: string;
constructor(response: PAIATokenResponseJson) {
super(response);
this.patron = response.patron;
}
toJson(): PAIATokenResponseJson {
return {
access_token: this.accessToken,
id_token: this.idToken,
refresh_token: this.refreshToken,
scope: this.scope,
token_type: this.tokenType,
issued_at: this.issuedAt,
expires_in: this.expiresIn?.toString(),
patron: this.patron,
};
}
isValid(buffer: number = AUTH_EXPIRY_BUFFER): boolean {
if (this.expiresIn) {
const now = nowInSeconds();
return now < this.issuedAt + this.expiresIn + buffer;
} else {
return true;
}
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AuthorizationServiceConfiguration, Requestor} from '@openid/appauth';
import {PAIATokenResponse} from './paia-token-response';
export interface UserInfoHandler {
performUserInfoRequest(
configuration: AuthorizationServiceConfiguration,
token: PAIATokenResponse,
): Promise<unknown>;
}
export class PAIAUserInfoHandler implements UserInfoHandler {
constructor(private requestor: Requestor) {}
public async performUserInfoRequest(
configuration: AuthorizationServiceConfiguration,
token: PAIATokenResponse,
): Promise<unknown> {
const settings: JQueryAjaxSettings = {
url: `${configuration.userInfoEndpoint}/${token.patron}`,
method: 'GET',
headers: {
Authorization: `${
token.tokenType == 'bearer' ? 'Bearer' : token.tokenType
} ${token.accessToken}`,
},
};
return this.requestor.xhr(settings);
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
TokenErrorJson,
AuthorizationServiceConfiguration,
RevokeTokenRequest,
Requestor,
JQueryRequestor,
BasicQueryStringUtils,
QueryStringUtils,
AppAuthError,
TokenError,
} from '@openid/appauth';
import {PAIATokenRequest} from './paia-token-request';
import {PAIATokenResponse, PAIATokenResponseJson} from './paia-token-response';
export class PAIATokenRequestHandler {
constructor(
public readonly requestor: Requestor = new JQueryRequestor(),
public readonly utils: QueryStringUtils = new BasicQueryStringUtils(),
) {}
private isTokenResponse(
response: PAIATokenResponseJson | TokenErrorJson,
): response is PAIATokenResponseJson {
return (response as TokenErrorJson).error === undefined;
}
performRevokeTokenRequest(
configuration: AuthorizationServiceConfiguration,
request: RevokeTokenRequest,
): Promise<boolean> {
const revokeTokenResponse = this.requestor.xhr<boolean>({
url: configuration.revocationEndpoint,
method: 'GET',
// headers: {'Content-Type': 'application/x-www-form-urlencoded'},
data: this.utils.stringify(request.toStringMap()),
});
return revokeTokenResponse.then(_response => {
return true;
});
}
performTokenRequest(
configuration: AuthorizationServiceConfiguration,
request: PAIATokenRequest,
): Promise<PAIATokenResponse> {
const tokenResponse = this.requestor.xhr<
PAIATokenResponseJson | TokenErrorJson
>({
url: configuration.tokenEndpoint,
method: 'POST',
data: {
patron: request.patron,
grant_type: 'client_credentials',
},
headers: {
'Authorization': `Basic ${request.code}`,
'Content-Type': 'application/json',
},
});
return tokenResponse.then(response => {
return this.isTokenResponse(response)
? new PAIATokenResponse(response)
: Promise.reject<PAIATokenResponse>(
new AppAuthError(response.error, new TokenError(response)),
);
});
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface IUserInfo {
display_name: string;
role: string;
email: string;
user_name: string;
}