Commit fc4f85fb authored by Eduardo Sanz Garcia's avatar Eduardo Sanz Garcia
Browse files

Merge branch 'newapi' into 'master'

breakage: simplified and opinionated API

Closes #3

See merge request tools-glue/angular-aap-auth!2
parents 487f09ee 8b691f71
Pipeline #2723 passed with stages
in 4 minutes and 3 seconds
......@@ -26,6 +26,15 @@ Angular version | angular-aap-auth version
## Consuming the library
The library exposes user information through `User` objects, which have information that's usually required for web application to work:
- The unique identifier (`uid`): if a unique identifier has to be used, use this field.
- Name (`name`): the full name of the user, for display purposes
- Nickname (`nickname`): if the user is an local aap account, it will contain the username, otherwise will have a weird string.
- Email (`email`): the account's email, this is for information only and several accounts may have the same username.
- Domains (`domains`): not directly provided. They may be misused into dong checking if the user has authorization to do some actions.
This should be done always server-side, if the domains information wants to be shown to the user as information it can still be done,
check the [Advanced usage](#advanced-usage) to see how to expose arbitrary token claims, or the embedded app.
In your Angular `AppModule` (app.module.ts):
```typescript
......@@ -67,7 +76,7 @@ export class AppModule {}
```
The default configuration uses localStorage to save the JWT token under the key
'id_token'. See [Advance usage](#advance-usage) for a more fine grained configuration.
'id_token'. See [Advanced usage](#advanced-usage) for a more fine grained configuration.
Example use on a component:
......@@ -82,7 +91,7 @@ import {
import {
AuthService,
Credentials
User
} from 'angular-aap-auth';
@Component({
......@@ -92,9 +101,9 @@ import {
<button (click)="auth.tabOpen()">Login new tab</button>
<button (click)="auth.logOut()">Logout</button>
<div *ngIf="(credentials | async) as user; else loggedOut">
<p>Real name: {{ user.realname }}</p>
<p>Username: {{ user.username }}</p>
<div *ngIf="(user | async) as user; else loggedOut">
<p>Name: {{ user.name }}</p>
<p>Unique Identifier: {{ user.uid }}</p>
<p>Email: {{ user.email }}</p>
<p>Token: {{ user.token }}</p>
</div>
......@@ -104,13 +113,13 @@ import {
`
})
export class AppComponent implements OnInit {
credentials: Observable < Credentials | null > ;
user: Observable < User | null > ;
constructor(
// Public for demonstration purposes
public auth: AuthService,
) {
this.credentials = auth.credentials();
this.user = auth.user();
}
ngOnInit() {
......@@ -120,68 +129,9 @@ export class AppComponent implements OnInit {
}
```
Alternative approach:
## Advanced usage
```typescript
import {
Component,
OnInit
} from '@angular/core';
import {
Observable,
} from 'rxjs';
import {
map
} from 'rxjs/operators';
import {
AuthService
} from 'angular-aap-auth';
@Component({
selector: 'app-root',
template: `
<button (click)="auth.windowOpen()">Login small window</button>
<button (click)="auth.tabOpen()">Login new tab</button>
<button (click)="auth.logOut()">Logout</button>
<p>Authenticated: {{ isAuthenticated|async }}</p>
<p>Real name: {{ realname|async }}</p>
<p>Username: {{ username|async }}</p>
<p>Token: {{ token|async }}</p>
`
})
export class AppComponent implements OnInit {
username: Observable < string | null > ;
realname: Observable < string | null > ;
email: Observable < string | null > ;
token: Observable < string | null > ;
isAuthenticated: Observable < string > ;
constructor(
// Public for demonstration purposes
public auth: AuthService,
) {
this.username = auth.username();
this.realname = auth.realname();
this.email= auth.email();
this.token = auth.token();
this.isAuthenticated = auth.isAuthenticated().pipe(
map(value => value && 'true' || 'false')
);
}
ngOnInit() {
this.auth.addLogInEventListener(() => console.log('Welcome'));
this.auth.addLogOutEventListener(() => console.log('Bye'));
}
}
```
## Advance usage
Advance module configuration:
Advanced module configuration:
```typescript
import {
......@@ -244,7 +194,7 @@ import {
OnInit
} from '@angular/core';
import {
Observable,
Observable
} from 'Observable';
import {
map
......@@ -252,28 +202,31 @@ import {
import {
AuthService,
TokenService // Only needed to inspect other claims in the JWT token
TokenService // Needed for JWT claim introspection
} from 'angular-aap-auth';
import {
JwtHelperService,
} from '@auth0/angular-jwt';
@Component({
selector: 'app-root',
template: `
<button (click)="openLoginWindow()">Login small window</button>
<button (click)="logOut()">Logout</button>
<p>Real name: {{ realname|async }}</p>
<p>Username: {{ username|async }}</p>
<p>Email: {{ email|async }}</p>
<p>Expiration: {{ expiration|async }}</p>
<p>ISS: {{ iss|async }}</p>
<p>Token: {{ token|async }}</p>
<div *ngIf="(user | async) as user; else loggedOut">
<p>Expiration Date: {{ expiration | async }}</p>
<p>Issuer: {{ iss | async }}</p>
</div>
<ng-template #loggedOut>
<p>Please, log in.</p>
</ng-template>
`
})
export class AppComponent implements OnInit {
username: Observable < string | null > ;
realname: Observable < string | null > ;
email: Observable < string | null > ;
token: Observable < string | null > ;
user: Observable < User | null > ;
// How to obtain other claims
expiration: Observable < Date | null > ;
......@@ -282,19 +235,24 @@ export class AppComponent implements OnInit {
constructor(
// Public for demonstration purposes
private auth: AuthService,
private jwt: TokenService
private tokens: TokenService,
private jwt: JwtHelperService
) {
this.username = auth.username();
this.realname = auth.realname();
this.email = auth.email();
this.token = auth.token();
this.expiration = this.token.pipe(
map(token => jwt.getTokenExpirationDate())
this.user = auth.user();
this.expiration = this.user.pipe(
map(_ => {
let token = this.tokens.getToken();
try {
return jwt.getTokenExpirationDate(<string>token);
} catch (e) {
return null;
}
})
);
this.iss = this.token.pipe(
map(token => jwt.getClaim < string, null > ('iss', null))
this.iss = this.user.pipe(
map(_ => jwt.getClaim < string, null > ('iss', null))
);
}
......
......@@ -9,22 +9,19 @@
<button (click)="auth.tabOpen({ttl: '1'})">Login new tab</button>
<button (click)="auth.logOut()">Logout</button>
</div>
<div *ngIf="(credentials | async) as user; else loggedOut">
<p>Real name: {{ user.realname }}</p>
<p>Username: {{ user.username }}</p>
<div *ngIf="(user | async) as user; else loggedOut">
<p>Unique ID: {{ user.uid }}</p>
<p>Name: {{ user.name }}</p>
<p>Nickname: {{ user.nickname }}</p>
<p>Email: {{ user.email }}</p>
<p>Token: {{ user.token }}</p>
</div>
<h3>Advanced handling of tokens:</h3>
<div>
<p>Can we present the token to servers? {{ isAuthenticated | async }}</p>
<div *ngIf="(expiration | async) as expiration;">
<p>Expiration date of the token: {{ expiration }}</p>
</div>
</div>
<ng-template #loggedOut>
<p>Please, log in.</p>
</ng-template>
<h3>Alternative approach</h3>
<div>
<p>Authenticated: {{ isAuthenticated|async }}</p>
<p>Real name: {{ realname|async }}</p>
<p>Username: {{ username|async }}</p>
<p>Email: {{ email|async }}</p>
<p>Expiration: {{ expiration|async }}</p>
<p>Token: {{ token|async }}</p>
</div>
......@@ -34,7 +34,7 @@ describe('AppComponent', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
app.isAuthenticated.subscribe((result: any) => {
expect(result).toEqual('false');
expect(result).toEqual('Nope');
});
}));
it('should render title in a h1 tag', async (() => {
......
......@@ -11,8 +11,11 @@ import {
import {
AuthService,
Credentials
User
} from 'app/modules/auth/auth.service';
import {
TokenService
} from 'app/modules/auth/token.service';
import {
JwtHelperService,
} from '@auth0/angular-jwt';
......@@ -23,44 +26,34 @@ import {
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
credentials: Observable< Credentials | null>;
user: Observable < User | null>;
// More specific
username: Observable < string | null > ;
realname: Observable < string | null > ;
email: Observable < string | null > ;
token: Observable < string | null > ;
isAuthenticated: Observable < string > ;
// How to obtain other claims
expiration: Observable < Date | null > ;
constructor(
// Public for demonstration purposes
public auth: AuthService,
private tokens: TokenService,
private jwt: JwtHelperService
) {
this.credentials = auth.credentials();
this.username = auth.username();
this.realname = auth.realname();
this.email = auth.email();
this.token = auth.token();
this.user = auth.user();
this.isAuthenticated = (auth.isAuthenticated()).pipe(
map(value => value && 'true' || 'false')
this.isAuthenticated = this.user.pipe(
map(value => value != null && 'Yes!' || 'Nope')
);
this.expiration = this.token.pipe(
map(token => {
this.expiration = this.user.pipe(
map(_ => {
const token = this.tokens.getToken();
try {
return jwt.getTokenExpirationDate(<string>token);
} catch (e) {
return null;
}
})
);
}
ngOnInit() {
......
......@@ -32,7 +32,6 @@ export function removeToken(): void {
],
imports: [
BrowserModule,
// AuthModule.forRoot(),
AuthModule.forRoot({
aapURL: 'https://api.aai.ebi.ac.uk',
tokenGetter: getToken,
......
......@@ -24,7 +24,7 @@ import {
DEFAULT_CONF
} from './auth.config';
describe('AuthService (valid token)', () => {
describe('AuthService with a non-expired token', () => {
let service: AuthService;
......@@ -49,54 +49,37 @@ describe('AuthService (valid token)', () => {
beforeEach(inject([AuthService], (serv: AuthService) => { service = serv; }));
it('should be created', () => {
it('must be created', () => {
expect(service).toBeTruthy();
});
it('should be authenticated', () => {
const isAuthenticated = service.isAuthenticated();
isAuthenticated.subscribe(result => expect(result).toBeTruthy());
});
it('should have credentials', () => {
const credentials = service.credentials();
credentials.subscribe(result => expect(result).toBeTruthy());
});
it('should have username', () => {
const username = service.username();
username.subscribe(result => expect(result).toEqual('usr-75f4b000'));
});
it('should have realname', () => {
const realname = service.realname();
realname.subscribe(result => expect(result).toEqual('Ed Munden Gras'));
});
it('should have email', () => {
const email = service.email();
email.subscribe(result => expect(result).toEqual('test@ebi.ac.uk'));
});
it('should have token', () => {
const token = service.token();
token.subscribe(result => expect(result).toEqual(VALID_TOKEN));
it('must provide a user with valid fields', () => {
const users = service.user();
users.subscribe(user => {
if (user != null) {
expect(user.uid).toEqual('usr-75f4b000');
expect(user.name).toEqual('Ed Munden Gras');
expect(user.nickname).toEqual('6f37a0beb7b16f37a0beb7b1b');
expect(user.email).toEqual('test@ebi.ac.uk');
}
});
});
// It doesn't work because async and timer issues
it('should have log out', fakeAsync(() => {
xit('must be able to log out', fakeAsync(() => {
let isAuthenticated = false;
service.isAuthenticated().subscribe(result => isAuthenticated = result);
service.user().subscribe(user => isAuthenticated = user != null);
flushMicrotasks();
expect(isAuthenticated).toEqual(true);
expect(isAuthenticated).toBe(true, 'user must be authenticated at this point');
// This doesn't work because the token is not coming from local storage but is a constant value.
// service.logOut();
// window.dispatchEvent(new Event('storage'));
// flushMicrotasks();
// expect(isAuthenticated).toEqual(false);
service.logOut();
window.dispatchEvent(new Event('storage'));
flushMicrotasks();
expect(isAuthenticated).toBe(false, 'user must not be authenticated at this point');
}));
it('should be correct single sign on URL', () => {
it('must create valid single-sign-on URL', () => {
expect(service.getSSOURL({
'ttl': '30',
'o': '3'
......@@ -104,7 +87,7 @@ describe('AuthService (valid token)', () => {
.toEqual('https://api.aai.ebi.ac.uk/sso?from=http%3A%2F%2Flocalhost%3A9876&ttl=30&o=3');
});
it('should be correct single sign on URL', () => {
it('must limit the single-sign-on time-to-live argument to 1440 seconds', () => {
expect(service.getSSOURL({
'ttl': '1441',
'o': '3'
......@@ -113,7 +96,7 @@ describe('AuthService (valid token)', () => {
});
});
describe('AuthService (expired token)', () => {
describe('AuthService with an expired token', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{
......@@ -133,39 +116,13 @@ describe('AuthService (expired token)', () => {
});
});
it('should be created', inject([AuthService], (service: AuthService) => {
it('must be created', inject([AuthService], (service: AuthService) => {
expect(service).toBeTruthy();
}));
it('should not be authenticated', inject([AuthService], (service: AuthService) => {
const isAuthenticated = service.isAuthenticated();
isAuthenticated.subscribe(result => expect(result).toBeFalsy());
}));
it('should not have credentials', inject([AuthService], (service: AuthService) => {
const credentials = service.credentials();
credentials.subscribe(result => expect(result).toBeNull());
}));
it('should not have username', inject([AuthService], (service: AuthService) => {
const username = service.username();
username.subscribe(result => expect(result).toBeNull());
}));
it('should not have realname', inject([AuthService], (service: AuthService) => {
const realname = service.realname();
realname.subscribe(result => expect(result).toBeNull());
}));
it('should not have email', inject([AuthService], (service: AuthService) => {
const email = service.email();
email.subscribe(result => expect(result).toBeNull());
it('must provide null instead of a user', inject([AuthService], (service: AuthService) => {
const users = service.user();
users.subscribe(user => expect(user).toBeNull());
}));
it('should not have token', inject([AuthService], (service: AuthService) => {
const token = service.token();
token.subscribe(result => expect(result).toBeNull());
}));
});
......@@ -26,17 +26,17 @@ export interface LoginOptions {
[key: string]: string;
}
export interface Credentials {
realname: string;
username: string;
export interface User {
uid: string;
name: string;
nickname: string;
email: string;
token: string;
}
@Injectable()
export class AuthService {
private _credentials = new BehaviorSubject < Credentials | null > (null);
private _user = new BehaviorSubject < User | null > (null);
private _loginCallbacks: Function[] = [];
private _logoutCallbacks: Function[] = [];
......@@ -72,45 +72,11 @@ export class AuthService {
this._listenLoginMessage(renderer);
this._listenChangesFromOtherWindows(renderer);
this._updateCredentials(); // TODO: experiment with setTimeOut
this._updateUser(); // TODO: experiment with setTimeOut
}
public isAuthenticated(): Observable < boolean > {
return this.fromCredentials(
credentials => credentials ? true : false
);
}
public credentials(): Observable < Credentials | null > {
return this.fromCredentials(credentials => credentials);
}
public realname(): Observable < string | null > {
return this.fromCredentials(
credentials => credentials ? credentials.realname : null
);
}
public username(): Observable < string | null > {
return this.fromCredentials(
credentials => credentials ? credentials.username : null
);
}
public email(): Observable < string | null > {
return this.fromCredentials(
credentials => credentials ? credentials.email : null
);
}
public token(): Observable < string | null > {
return this.fromCredentials(
credentials => credentials ? credentials.token : null
);
}
private fromCredentials < T > (extractor: (_: Credentials) => T ): Observable < T > {
return this._credentials.asObservable().pipe(map(extractor));
public user(): Observable < User | null > {
return this._user.asObservable();
}
/**
......@@ -240,7 +206,7 @@ export class AuthService {
*/
public logOut() {
this._storageRemover();
this._updateCredentials();
this._updateUser();
// Triggers updating other windows
this._commKeyUpdater();
......@@ -299,12 +265,12 @@ export class AuthService {
*/
private _listenLoginMessage(renderer: Renderer2) {
renderer.listen('window', 'message', (event: MessageEvent) => {
if (!this.messageIsAcceptable(event)) {
if (!this._messageIsAcceptable(event)) {
return;
}
this._storageUpdater(event.data);
event.source.close();
this._updateCredentials();
this._updateUser();
// Triggers updating other windows
this._commKeyUpdater();
......@@ -322,7 +288,7 @@ export class AuthService {
private _listenChangesFromOtherWindows(renderer: Renderer2) {
renderer.listen('window', 'storage', (event: StorageEvent) => {
if (event.key === this._commKeyName) {
this._updateCredentials();
this._updateUser();
}
});
}
......@@ -331,23 +297,21 @@ export class AuthService {
* Check if the message is coming from the same domain we use to generate
* the SSO URL, otherwise it's iffy and shouldn't trust it.
*/
private messageIsAcceptable(event: MessageEvent): boolean {
private _messageIsAcceptable(event: MessageEvent): boolean {
return event.origin === this._appURL;
}
private _updateCredentials() {
const isAuthenticated = this._loggedIn();
private _updateUser() {
if (this._timeoutID) {
window.clearTimeout(this._timeoutID);
}
if (isAuthenticated) {
this._credentials.next({
realname: < string > this._getRealName(),
username: < string > this._getUserName(),
email: < string > this._getEmail(),
token: < string > this._getToken()
if (this._tokenService.isTokenValid()) {
this._user.next({
uid: < string > this._getClaim('sub'),