Setting up a site with mandatory login and basic edge cases is insanely complicated
See original GitHub issueDescribe 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.
Issue Analytics
- State:
- Created 2 years ago
- Reactions:4
- Comments:7
Top GitHub Comments
yes, thanks I found your sample (even starred it to find it later, but couldn’t 😛) but found it way too complex to integrate into my app, and also I don’t required route guards, but after all the things I ended up doing, I might havae as well used your
auth.service.ts
.And even with that, your
runInitialLoginSequence
is asynchronous so if unguarded components just do some HTTP requests in their ngOnOnit, those requests won’t have a valid token and will fail. (that’s where adding a guard to every component could make sense, but is very cumbersome)I used to use a server side guard before doing it client side, and it was much easier to handle…
IMO we need to warn users about those timings issues that are not really documented and can drive you crazy:
waitForTokenInMs
default value to > 0, something like 1000 would make senseif (svc.getAccessToen() && svc.hasValidAccessToken() == false)) svc.destroyAccessToken()
I think you need to use the sessionStorage at the some point because of the redirect, you lose the JS context.