Description
Describe the bug
Avoid entering a redirect loop, avoid requests triggered before valid token is available (those will trigger errors client side), handling the refresh_token flow, redirecting user to initial url after login, new tabs share the same credentials than first tab...
All of those seems to be basic features you would like built-in in oauth2 framework like this. However, here how my implementation turned out and I am really disappointed by its lack of simplificity nor existing example that cover all of those.
How my implementation looks like
Read the comments to understand why I did things like this...
oauth.module.ts with useful configs
declare global {
interface Window { xTenantId: string; xClientId: string } // declared in _Layout.cshml
}
const xTenandId = window.xTenantId;
const xClientId = window.xClientId;
export const authCodeFlowConfig: AuthConfig = {
// Url of the Identity Provider
issuer: `https://login.microsoftonline.com/${xTenandId}/v2.0`,
// URL of the SPA to redirect the user to after login
redirectUri: location.origin + URL_BASE + '/login-redirect', // unique route to prevent loops
// The SPA's id. The SPA is registerd with this id at the auth-server
clientId: xClientId,
responseType: 'code',
// set the scope for the permissions the client should request
// The first four are defined by OIDC.
// Important: Request offline_access to get a refresh token
// The api scope is a usecase specific one
scope: `openid offline_access api://${xClientId}/.default`,
showDebugInformation: true, //!environment.production,
// turn off validation that discovery document endpoints start with the issuer url defined above because some url starts with sts.windows.net instead of login.microsoftonline.com
strictDiscoveryDocumentValidation: false,
postLogoutRedirectUri: location.origin + URL_BASE,
redirectUriAsPostLogoutRedirectUriFallback: false, // else it redirects to redirectUri ie '/login-redirect' and user stays stuck there
waitForTokenInMsec: 8000, // set a big number, else it will just trigger HTTP requests without a valid token, and we don't want to trigger requests without token anyway
}
// We need a factory, since localStorage is not available during AOT build time.
export function storageFactory(): OAuthStorage {
return new myAppLocalStorage(); // else it defaults to sessionStorage, which is not even shared by tab
}
class myAppLocalStorage implements OAuthStorage {
prefix = 'myapp_'; // to prevent conflict with other apps on same domain
getItem(key: string): string {
return localStorage.getItem(this.prefix + key);
}
removeItem(key: string): void {
localStorage.removeItem(this.prefix + key);
}
setItem(key: string, data: string): void {
localStorage.setItem(this.prefix + key, data);
}
}
@NgModule({
declarations: [],
imports: [OAuthModule.forRoot({
resourceServer: {
allowedUrls: [API_URL_BASE],
sendAccessToken: true
}
}),],
providers: [
{ provide: OAuthStorage, useFactory: storageFactory }
]
})
export class XOauthModule { }
app.component.ts with most of the logic to know when to redirect to login
ngOnInit() {
console.log('AppComponent - ngOnInit');
// for debugging
this.oauthSvc.events.subscribe(event => {
if (event instanceof OAuthErrorEvent) {
console.error('oauthService event', event);
} else {
console.debug('oauthService event', event);
}
});
this.oauthSvc.configure(authCodeFlowConfig);
this.oauthSvc.setupAutomaticSilentRefresh();
// Inspiration but not totally enough: Manually Skipping the Login Form -- https://manfredsteyer.github.io/angular-oauth2-oidc/docs/additional-documentation/manually-skipping-login-form.html
// IMPORTANT: doing the checks in a synchronous way so chikdren components don't trigger unauthorized ajax calls in the meanwhile
let validToken = this.oauthSvc.hasValidAccessToken();
let waitForAuth$ = validToken ? of(true) : this.oauthSvc.events.pipe(filter(e => e.type === 'token_received'), map(() => true));
console.debug('AppComponent - has validToken? ', validToken);
if (validToken === false) {
// WARNING prevent login loop, after login, the token can take time to arrive, so we need to check we only redirect to login the first time
if (!sessionStorage.getItem('LS_REDIRECT_URL')) { // not logged in and not in process of authenticating
console.debug('invalidToken, not in process of authenticating');
if (this.oauthSvc.getRefreshToken()) { // check if refresh token is there, if so uses it instead of redirecting
console.debug('invalidToken, trying to refresh');
storageFactory().removeItem('access_token'); // clears the expired access_token so it's not used for early ajax requests before the token is refreshed
this.oauthSvc.loadDiscoveryDocument().then(() => {
console.debug('AppComponent - loadDiscoveryDocument');
this.oauthSvc.refreshToken().catch(e => {
// refresh_token is probably expired, so redirect to login required
console.warn('error refreshing token', e);
this.redirectToLogin();
});
});
} else {
console.debug('invalidToken, in process of authenticating with LS_REDIRECT_URL=', sessionStorage.getItem('LS_REDIRECT_URL'))
this.oauthSvc.loadDiscoveryDocument().then(() => {
console.debug('AppComponent - loadDiscoveryDocument');
this.redirectToLogin();
});
}
} else { // still getting token from code flow
console.debug('AppComponent - still getting token from code flow');
this.oauthSvc.loadDiscoveryDocumentAndTryLogin().then((hasReceivedTokens) => {
console.debug('AppComponent - loadDiscoveryDocumentAndTryLogin', hasReceivedTokens);
});
}
} else { // already logged in
this.oauthSvc.loadDiscoveryDocument().then((hasReceivedTokens) => {
console.debug('AppComponent - loadDiscoveryDocumentAndTryLogin', hasReceivedTokens);
});
}
waitForAuth$.subscribe(() => {
let accessToken = this.oauthSvc.getAccessToken();
console.debug('AppComponent - hasValidAccessToken', accessToken);
this.webSocketService.initConnection(accessToken); // get accessToken and pass to config.qs
});
}
redirectToLogin() {
//debugger;
let redirectUrl = location.href.replace(location.origin + URL_BASE, '');
console.debug('redirectToLogin with LS_REDIRECT_URL=', redirectUrl);
sessionStorage.setItem('LS_REDIRECT_URL', redirectUrl); // allows us to know which url the user requested, and also useful to know that we already redirected to login, so we don't enter a login loop
this.oauthSvc.initLoginFlow();
}
loginRedirect.component.ts
@Component({
selector: "app-redirect",
template: `<pre>Logged in successfully, redirecting to requested url... If it does not work, <button [routerLink]="['/']">Go home</button></pre>`
})
export class AuthRedirectComponent implements OnInit {
constructor(private router: Router,
private oauthService: OAuthService) { }
ngOnInit(): void {
console.log("AuthRedirectComponent activated, state=" + this.oauthService.state);
//debugger;
if (this.oauthService.hasValidAccessToken()) {
this.redirect();
} else {
this.oauthService.events.pipe(filter((e) => e.type === 'token_received'), take(1)).subscribe((e) => {
this.redirect();
});
}
}
redirect() {
const url = sessionStorage.getItem('LS_REDIRECT_URL');
if (url) {
sessionStorage.removeItem('LS_REDIRECT_URL');
this.router.navigateByUrl(decodeURIComponent(url));
} else
this.router.navigate(["/"]);
}
}
Additional context
Hopefully it helps someone who have the same needs, and you can provide feedback it you feel it could be improved somehow.
Maybe adding a sample that covers all of use cases could be useful to others.
Thanks.