Integrating Okta into an Angular SaaS application
Before we get started, let me explain what Okta is. Okta is an identity management and access management SaaS platform that helps organizations manage and secure user authentication into applications. More importantly, it also provides developers with tools to build identity controls into their applications.
When initially building our SaaS application, we didn’t consider Okta as an option and simply went with M365 and Google authentication. However as more customers came on board, the requests for Okta mounted, so we gave in and I got started with the integration.
Before I get into the technical details, I will say that we are relying on customers to create an Application within their own Okta portal and that we aren’t (yet) creating a global Application within Okta’s App Catalogue. The reason for this is that we wanted to get the integration working before putting time in to get something added to the App Catalogue. Also, Okta took a ridiculous amount of time to integrate and for a long time I just couldn’t get Okta auth working when deployed to a production environment. There were no errors, the auth process simply didn’t complete! The only way I was able to get it working was by completely omitting the @okta/okta-angular
package completely and instead write the auth code myself using @okta/okta-auth-js
package.
Let's dig in.
First things first, if you have any reference to @okta/okta-angular
anywhere in your project, you’ll need to declare the OKTA_CONFIG
which requires a fully declared OktaAuth client.
import { NgModule } from '@angular/core';
import { MapModule } from '@spaces/map';
import { OktaAuth, OktaAuthOptions } from '@okta/okta-auth-js';
import { OKTA_CONFIG } from '@okta/okta-angular';
...
const config: OktaAuthOptions = {
issuer: 'https://[placeholder-issuer].com',
clientId: '[placeholder-clientid]',
redirectUri: '/login/callback'
};
export const authClient = new OktaAuth(config);
@NgModule({
declarations: [AppComponent],
imports: [
...
],
providers: [
...
{
provide: OKTA_CONFIG,
useValue: { authClient }
},
...
],
bootstrap: [AppComponent]
})
export class AppModule {}
The first thing to notice is that Okta requires an issuer
, clientId
and redirectUri
, however, if your SaaS application is the same as mine, you probably don’t know the customers issuer
and clientId
until after the user is interacting with the application. It seems that these values aren’t actually used and anything can be entered, so I choose to use [placeholder-issuer]
and [placeholder-clientid]
it seems weird but it works. Alternatively, you can remove all references to the @okta/okta-angular
package which removes the need for this entirely which is what I eventually did.
Perform the Redirect
Next, you want a sign in button and something to start the sign in process.
async signInWithOkta(email: string) {
const redirectUri = `${window.location.origin}${
window.location.pathname !== '/' ? this.window.location.pathname : ''
}/callback`;
const issuer = '' // get the customers specific issuer should be this format `https://${domain}/oauth2/default`
const clientId = '' // get the customers specific client ID
const config: OktaAuthOptions = {
issuer: issuer,
clientId: clientId,
redirectUri: redirectUri,
pkce: true,
storageManager: {
token: {
storageTypes: ['localStorage', 'sessionStorage', 'cookie']
},
cache: {
storageTypes: ['localStorage', 'sessionStorage', 'cookie']
},
transaction: {
storageTypes: ['cookie']
}
}
};
store('okta-config', config);
const authClient = new OktaAuth(config);
await authClient.token.getWithRedirect({
scopes: ['openid', 'profile', 'email'],
responseType: ['token', 'id_token'],
prompt: 'consent login',
loginHint: email
});
}
Building the Redirect URI
Let's break this down, we need to build the redirect URI and ensure that your redirect URI matches exactly the URL that is going to be processing the callback. In my case, our application is built into multiple languages and so the below code does that for me.
const redirectUri = `${window.location.origin}${
window.location.pathname !== '/' ? this.window.location.pathname : ''
}/callback`;
Store the Okta config
We want to store the config somewhere to be used within the callback component. I don’t see an issue with storing this in local storage, so I’m using the store2
package to do this.
store('okta-config', config);
Do the Redirect
We want to initialize the authClient property with the config, set up the scopes we want to use, and finally to help with the user's experience, provide the email into Okta as a login hint (assuming you have it). This part isn’t important but is nice so the user doesn’t have to enter their email twice.
const authClient = new OktaAuth(config);
await authClient.token.getWithRedirect({
scopes: ['openid', 'profile', 'email'],
responseType: ['token', 'id_token'],
prompt: 'consent login',
loginHint: email
});
Catch the Redirect
Let's add a route to the AppModule and declare a CallbackComponent. This is where if we were using the @okta/okta-angular
package we would be able to use the pre-built callback component, however as I said. This didn’t work for me so I built my own.
import { NgModule } from '@angular/core';
import { MapModule } from '@spaces/map';
import { OktaAuth, OktaAuthOptions } from '@okta/okta-auth-js';
...
@NgModule({
declarations: [AppComponent],
imports: [
...
RouterModule.forChild([
...
{
path: 'login/callback',
component: CallbackComponent
}
...
]),
],
providers: [
...
],
bootstrap: [AppComponent]
})
export class AppModule {}
Now the callback component.
import { Component, OnInit } from '@angular/core';
import { OktaAuth, OktaAuthOptions } from '@okta/okta-auth-js';
import store from 'store2';
@Component({
template: `
...
{{error}}
`,
styles: [
`
...
`
]
})
export class OktaCallbackComponent implements OnInit {
error?: string;
constructor() {}
async ngOnInit(): Promise<void> {
try {
const config: OktaAuthOptions = store('okta-config');
const authClient = new OktaAuth(config);
if (authClient.isLoginRedirect()) {
// Parse token from redirect url
authClient.token.parseFromUrl().then((data) => {
const { accessToken, idToken } = data.tokens;
console.log(accessToken, idToken)
});
} else {
// The URL is wrong lets re-try
authClient.token.getWithRedirect({
responseType: ['token', 'id_token']
});
}
} catch (err) {
this.error = err;
}
}
}
You might want to add a template and styles so you know something working, you can also add a try-catch around your code and log the error into a local parameter that will print to the page. But other than that let's delve into the code a bit more.
Get stored config
Let's get the previously stored config out of local storage, this is the best approach I found as after the redirect, I don’t have access to the clientId and issuer for the customer. So storing in local storage and retrieving in the callback resolves this.
const config: OktaAuthOptions = store('okta-config');
const authClient = new OktaAuth(config);
Check the URL
You want to check that the current request is a redirect and if not perform a redirect. This is optional, however, I personally think this is useful as you can catch bad requests and attempt a better one. Also, this is a good way of debugging your redirect. If your URL is incorrect Okta can’t perform the auth, and you’ll find yourself in an infinite redirect loop.
if (authClient.isLoginRedirect()) {
} else {
authClient.token.getWithRedirect({
responseType: ['token', 'id_token']
});
}
Get the tokens
Finally and most importantly, let's get the all-important tokens. Because we requested the response type to be both token and id_token our response will be an object including accessToken
and idToken
authClient.token.parseFromUrl().then((data) => {
const { accessToken, idToken } = data.tokens;
// do something with the Okta tokens
});
Once you have the accessToken and idToken objects you’re fully authenticated and can log the user into your application. In my case, I need to transpose the Okta token into a token that my application understands. So I send the accessToken.accessToken
and accessToken.userinfoUrl
to my API. This enables me to get the Okta user from the Okta API and use that information to generate my own token.
const config = {
headers: {
Authorization: `Bearer ${accessToken.accessToken}`,
},
};
const res = await axios.get(accessToken.userinfoUrl, config);
However, this step isn’t necessary if you don’t require your own token.
Conclusion
This entire code took much, much longer than it needed to, and that was all because I was determined that I needed to use the @okta/okta-angular
package. As soon as I removed that everything worked almost first time. I suppose the morale of the story is don’t assume that a package with over 30k weekly downloads will work.
Would I recommend Okta? Unless you have a strong reason e.g. your customer base requires it. I personally wouldn’t pick Okta as my default Auth provider, other options work just as well if not better also compared to other Auth providers such as Google Workspace or Microsoft the documentation and community support is lacking. But hopefully, this article helps with that and others don’t have to go through the pain that I did.
Drop me a follow or Subscribe to me for more like this or email me with anything you want me to talk about.