Commit 8b691f71 authored by Pau Ruiz i Safont's avatar Pau Ruiz i Safont
Browse files

breakage: simplified and opinionated API

Now we use `User` object instead of 'Credentials'.
The token is not exposed anymore through the auth, TokenService needs to
be used to access it.
Domains are not exposed for the time being as it can give a false sense
that checking domains in a web app is somehow accepted or even
recommended for security purposes, this is not the case.
parent 487f09ee
......@@ -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'),
name: < string > this._getClaim('name'),