Setup Okta
Learn how to configure Okta with a multi-tenant Webiny project
Webiny Enterprise license is required to use this feature.
This feature is available since Webiny v5.22.0.
- how to integrate Okta with a multi-tenant project
Overview
Okta integration replaces the default Cognito setup, and allows companies to manage users and their access to Webiny instances from within Okta. Since Webiny’s system of permissions contains rich permission objects, you can’t define these in Okta. Instead, assignment to security groups is happening using JSON Web Token (JWT) claims, which help you map every identity to a specific security group within Webiny, on a particular tenant.
1) Prepare the Project
Your project needs to be at version5.22.0
to use this feature.
Please follow the upgrade guide to upgrade your project to the appropriate version.Alternatively, you can create a new >=5.22.0
project, by running:
npx create-webiny-project my-project
If creating a new project, before following further instructions, make sure you complete the initial setup wizard with Cognito. This is a required step to successfully replace Cognito with Okta.
2) Add New Dependencies
We need to add several new packages to the project.
Add Okta module dependency to the GraphQL API dependencies:
yarn workspace api-graphql add @webiny/api-security-okta
Add Okta module dependency to the Headless CMS API dependencies:
yarn workspace api-headless-cms add @webiny/api-security-okta
Add Okta module dependency to the Admin app dependencies:
yarn workspace admin add @webiny/app-admin-okta
3) Configure Okta in the GraphQL API
We need to update security configuration in 2 Lambda functions: graphql
and headlessCMS
.
The difference between your original file and the one below, is that we removed all Cognito plugins, and added Okta plugin instead, with some other tweaks to the security configuration. The most important changes are highlighted and commented for your convenience.
- Main GraphQL API
- Headless CMS API
import { DocumentClient } from "aws-sdk/clients/dynamodb";import { createTenancyContext, createTenancyGraphQL } from "@webiny/api-tenancy";import { createStorageOperations as tenancyStorageOperations } from "@webiny/api-tenancy-so-ddb";import { createSecurityContext, createSecurityGraphQL } from "@webiny/api-security";import { createStorageOperations as securityStorageOperations } from "@webiny/api-security-so-ddb";import { authenticateUsingHttpHeader } from "@webiny/api-security/plugins/authenticateUsingHttpHeader";import apiKeyAuthentication from "@webiny/api-security/plugins/apiKeyAuthentication";import apiKeyAuthorization from "@webiny/api-security/plugins/apiKeyAuthorization";import anonymousAuthorization from "@webiny/api-security/plugins/anonymousAuthorization";import { createOkta } from "@webiny/api-security-okta";
export default ({ documentClient }: { documentClient: DocumentClient }) => [ /** * Create Tenancy app in the `context`. */ createTenancyContext({ storageOperations: tenancyStorageOperations({ documentClient }) }),
/** * Expose tenancy GraphQL schema. */ createTenancyGraphQL(),
/** * Create Security app in the `context`. */ createSecurityContext({ /** * For Okta, this must be set to `false`, as we don't have links in the database. */ verifyIdentityToTenantLink: false, storageOperations: securityStorageOperations({ documentClient }) }),
/** * Expose security GraphQL schema. */ createSecurityGraphQL({ /** * For Okta, we must provide custom logic to determine the "default" tenant for current identity. * Since we're not linking identities to tenants via DB records, we can just return the current tenant. */ async getDefaultTenant(context) { return context.tenancy.getCurrentTenant(); } }),
/** * Perform authentication using the common "Authorization" HTTP header. * This will fetch the value of the header, and execute the authentication process. */ authenticateUsingHttpHeader(),
/** * Configure Okta authentication and authorization. */ createOkta({ /** * `issuer` is required for token verification. */ issuer: process.env.OKTA_ISSUER as string, /** * Construct the identity object and map token claims to arbitrary identity properties. */ getIdentity({ token }) { return { id: token.sub, type: "admin", displayName: token.name, group: token.webiny_group }; }, /** * Get the slug of a security group to fetch permissions from. */ getGroupSlug(context) { return context.security.getIdentity().group; } }),
/** * API Key authenticator. * API Keys are a standalone entity, and are not connected to users in any way. * They identify a project, a 3rd party client, not a particular user. * They are used for programmatic API access, CMS data import/export, etc. */ apiKeyAuthentication({ identityType: "api-key" }),
/** * Authorization plugin to fetch permissions for a verified API key. * The "identityType" must match the authentication plugin used to load the identity. */ apiKeyAuthorization({ identityType: "api-key" }),
/** * Authorization plugin to load permissions for anonymous requests. * This allows you to control which API resources can be accessed publicly. * The authorization is performed by loading permissions from the "anonymous" user group. */ anonymousAuthorization()];
The last thing to do is to define the OKTA_ISSUER
environment variable, and pass it to the Pulumi configuration.
In your project directory, open the .env
file, and define the OKTA_ISSUER
variable, using your authorization server URI. You can find this URI in the Okta dashboard, by navigating to Security -> API.
OKTA_ISSUER=https://dev-123456.oktapreview.com/oauth2/default
This will ensure that, when Pulumi starts the deploy process, this environment variable is present in process.env
.
Now we need to pass it to the GraphQL API and Headless CMS API Lambda functions. Open api/pulumi/dev/index.ts
and add the OKTA_ISSUER
key to environment variable definitions, as shown below:
const api = new Graphql({env: { COGNITO_REGION: String(process.env.AWS_REGION), COGNITO_USER_POOL_ID: cognito.userPool.id, DB_TABLE: dynamoDb.table.name, OKTA_ISSUER: process.env.OKTA_ISSUER, PRERENDERING_RENDER_HANDLER: prerenderingService.functions.render.arn, PRERENDERING_FLUSH_HANDLER: prerenderingService.functions.flush.arn, PRERENDERING_QUEUE_ADD_HANDLER: prerenderingService.functions.queue.add.arn, PRERENDERING_QUEUE_PROCESS_HANDLER: prerenderingService.functions.queue.process.arn, S3_BUCKET: fileManager.bucket.id, IMPORT_PAGES_CREATE_HANDLER: pageBuilder.functions.importPages.create.arn, EXPORT_PAGES_PROCESS_HANDLER: pageBuilder.functions.exportPages.process.arn, DEBUG, WEBINY_LOGS_FORWARD_URL},primaryDynamodbTable: dynamoDb.table,bucket: fileManager.bucket,cognitoUserPool: cognito.userPool});
const headlessCms = new HeadlessCMS({env: { COGNITO_REGION: String(process.env.AWS_REGION), COGNITO_USER_POOL_ID: cognito.userPool.id, DB_TABLE: dynamoDb.table.name, S3_BUCKET: fileManager.bucket.id, OKTA_ISSUER: process.env.OKTA_ISSUER, DEBUG, WEBINY_LOGS_FORWARD_URL},primaryDynamodbTable: dynamoDb.table});
Definition of this environment variable on Lambda functions has to be repeated for all infrastructure stacks you’re using in your project. If you’re also using the prod
stack, make sure you also add these variables in the respective Lambda functions in the prod
stack configuration, such as api/pulumi/prod/index.ts
and all the other stacks you might have created yourself.
4) Configure Admin App
Open apps/admin/code/src/App.tsx
and replace the Cognito plugin with Okta plugin:
import React from "react";import { Admin } from "@webiny/app-serverless-cms";import { Okta } from "@webiny/app-admin-okta";import { TenantManager } from "@webiny/app-tenant-manager";import "./App.scss";
import { oktaFactory, rootAppClientId } from "./okta";
export const App = () => {return ( <Admin> <Okta factory={oktaFactory} rootAppClientId={rootAppClientId} /> <TenantManager /> </Admin>);};
Next, we need to add this new ./okta
file which contains the factory for Okta SignIn Widget and Okta Auth JS . This file allows you to configure the entire Okta SDK however you like, and also apply style changes if you need them.
Copy the following code, and place it in apps/admin/code/src/okta.ts
:
import { OktaFactory } from "@webiny/app-admin-okta";
import OktaSignIn from "@okta/okta-signin-widget";
import { OktaAuth } from "@okta/okta-auth-js";
import "@okta/okta-signin-widget/dist/css/okta-sign-in.min.css";
const redirectUri = window.location.origin + "/";
// These scopes are required to populate all the necessary user data on the API side.
const scopes = ["openid", "email", "profile"];
const oktaDomain = "{YOUR_OKTA_DOMAIN}"; // Example: https://dev-123456.oktapreview.com
export const rootAppClientId = "{YOUR_ROOT_TENANT_APP_CLIENT_ID}";
export const oktaFactory: OktaFactory = ({ clientId }) => {
const oktaSignIn = new OktaSignIn({
// Additional documentation on config options can be found at https://github.com/okta/okta-signin-widget#basic-config-options
baseUrl: oktaDomain,
clientId,
redirectUri,
logo: "https://raw.githubusercontent.com/webiny/webiny-js/next/static/webiny-logo.svg",
authParams: {
scopes
}
});
const oktaAuth = new OktaAuth({
issuer: `${oktaDomain}/oauth2/default`,
clientId,
redirectUri,
scopes,
pkce: true,
restoreOriginalUri: null,
devMode: false
});
return { oktaSignIn, oktaAuth };
};
We’re using Okta SignIn Widget to handle the whole login process, so we need to import and configure it. The reason we’re exporting a factory is that we need to obtain an Okta App Client ID depending on the tenant we’re trying to access. We do that by sending a GraphQL query to our API, which returns the App Client ID for the given tenant. Only then we can configure the Okta SignIn Widget and start the authentication flow. App Client ID configuration for tenants is described in the next section.
There are two important variables you need to insert into this new okta.ts
file, oktaDomain and rootAppClientId. Even though we obtain App Client IDs through the API, we’re unable to do it for the root tenant, since we need to login into the root tenant to execute the installation wizard (chicken & egg problem). So, for the root tenant, we must have its App Client ID present in the code.
With this, you’re ready to deploy the project. To deploy the whole project, run the following:
yarn webiny deploy --env=dev
Or deploy just the api
and admin
by running:
yarn webiny deploy api --env=dev && yarn webiny deploy apps/admin --env=dev
5) Configuring App Client ID for New Tenants
Once your project is deployed, open your Admin app. If everything went well, you should be presented with an Okta login screen. The Okta user you’re using to login must have access to the root tenant app client, and have the appropriate claims assigned to the user’s JWT so the user can be mapped to the correct security group, as described in the Configure Okta in the GraphQL API section.We recommend that your main root tenant admin user has a full-access
group assignment. Think of this user as a super admin user who can run system setup, create new tenants, and so on. A full-access
security group is always present in the system. So, you don’t need to create it manually.
Once you’ve logged in, navigate to Tenant Manager -> Tenants. In the tenant creation form, there is a dedicated input for Okta App Client ID:
The value entered here is what the Admin app will be fetching via the API during app bootstrap (on every browser reload). Since multiple tenants can use the same app client ID, for development purposes, you can have 1 Okta app shared between all the developers in the team, across all tenants in their development environments.
6) Accessing Admin App of a Particular Tenant
To point the browser to a specific tenant you want to access, you simply need to use the tenantId query string parameter:
http://localhost:3001/?tenantId=root
http://localhost:3001/?tenantId=61e6db7813ab8e0009e252d1
...
For production environments, this query parameter is exactly how you’ll setup the Initiate login URI in your Okta dashboard, to point users to the respective tenant.
For development purposes, though, developers will need to specify the query string parameter manually in the browser, since they’ll most likely be sharing the same Okta app client, but their tenant IDs will be different.