From 4e57c829311f288a521d0933ef33e10e43e625fd Mon Sep 17 00:00:00 2001 From: daedalus <44623501+ComfortablyCoding@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:34:12 -0400 Subject: [PATCH] rework 3rd party jwt post --- ...validating-third-party-jwts-in-directus.md | 264 ++++++++++++++---- 1 file changed, 209 insertions(+), 55 deletions(-) diff --git a/content/tutorials/5.extensions/validating-third-party-jwts-in-directus.md b/content/tutorials/5.extensions/validating-third-party-jwts-in-directus.md index 75f44530..5d0c9bdb 100644 --- a/content/tutorials/5.extensions/validating-third-party-jwts-in-directus.md +++ b/content/tutorials/5.extensions/validating-third-party-jwts-in-directus.md @@ -2,13 +2,13 @@ stableId: 3b58ec70-1bee-4e25-9a8c-8db924f204cd slug: validating-third-party-jwts-in-directus title: Validating Third-Party JWTs in Directus (with Okta) -description: Learn how to translate third part JWT's to Directus accountability +description: Learn how to translate third-party JWTs to Directus accountability authors: - name: Judd Abraham title: Senior Software Engineer --- -Integrating external identity providers (e.g. Okta) with Directus often results in the need to accept a third-party JWT, validate it and translate it into a Directus **Accountability** object. This allows clients to authenticate with their external provider while Directus still enforcing its access controls. +Integrating external identity providers (e.g. Okta) with Directus often results in the need to accept a third-party JWT, validate it and translate it into a Directus **Accountability** object. This allows clients to authenticate with their external provider while Directus enforces access controls. This guide walks through implementing this flow using Directus's `authenticate` hook. This guide assumes the provider exposes a [JWK](https://www.rfc-editor.org/rfc/rfc7517) endpoint. @@ -16,13 +16,13 @@ This guide walks through implementing this flow using Directus's `authenticate` Directus exposes an `authenticate` [filter hook](/guides/extensions/api-extensions/hooks#filter) that lets us override its authentication logic. This hook runs on every request that requires authentication. -We will [initialize a new hook extension](/guides/extensions/quickstart#initializing-an-extension) named `okta-jwt` written in typescript. Once that is generated replace the contents of `index.ts` with the following code snippet: +We will [initialize a new hook extension](/guides/extensions/quickstart#initializing-an-extension) named `okta-jwt` written in TypeScript. Once that is generated replace the contents of `index.ts` with the following code snippet: ```ts import { defineHook } from "@directus/extensions-sdk"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // }); }); @@ -32,15 +32,16 @@ export default defineHook(({ filter }, { logger }) => { Now that we've set up the hook our next step is to read the JWT passed with the request. -In our case it is sent as a Bearer token and therefore will be accessible on the request's `token` property. The original request object is provided under the hooks `meta` property. +In our case it is sent as a Bearer token and therefore will be accessible on the request's `token` property. The original request object is provided under the hook's `meta` property. ```ts import { defineHook } from "@directus/extensions-sdk"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { - // Access 3rd party JWT - const token = meta.req.token; +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { + // Access 3rd party JWT + const token = meta.req.token; + }); }); ``` @@ -55,14 +56,14 @@ import { InvalidTokenError } from "@directus/errors"; import { defineHook } from "@directus/extensions-sdk"; import { jwtDecode } from "jwt-decode"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // ... (code from step 2) ... - // Attempt to decode payload for later user + // Attempt to decode payload for later use let payload; try { - payload = await jwtDecode(token); + payload = jwtDecode(token); } catch (error) { logger.error(error); // Invalid JWT, have directus resume its normal flow @@ -75,7 +76,7 @@ export default defineHook(({ filter }, { logger }) => { // Attempt to access the `kid` header, required for verification let headers; try { - headers = await jwtDecode(token, { header: true }); + headers = jwtDecode(token, { header: true }); } catch (error) { logger.error(error); // Invalid JWT, have directus resume its normal flow @@ -95,7 +96,7 @@ export default defineHook(({ filter }, { logger }) => { ## 4. Fetching the Signing Key -Now that we have the header we fetch the secret that used for the token. The JWK endpoint for Okta is `https:///oauth2/default/v1/keys`. +Now that we have the header we fetch the secret used to sign the token. The JWK endpoint for Okta is `https:///oauth2/default/v1/keys`. We will use the [`jwks-rsa`](https://github.com/auth0/node-jwks-rsa) library to handle the retrieval of the secret. @@ -105,8 +106,8 @@ import { defineHook } from "@directus/extensions-sdk"; import { jwtDecode } from "jwt-decode"; import jwksClient from "jwks-rsa"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // ... (code from step 2-3) ... const client = jwksClient({ @@ -133,11 +134,11 @@ export default defineHook(({ filter }, { logger }) => { ## 5. Verifying the JWT with the Signing Key -After obtaining the secret we can verify the token is authenticate and has not been tampered with. +After obtaining the secret we verify the token is authentic and has not been tampered with. -To further strength security, we will also validate any additional information we know, such as `alogrithm`, `issuer` and `audience` and will add those as verification requirements. +To further strengthen security, we will also validate any additional information we know, such as `algorithm`, `issuer` and `audience` and will add those as verification requirements. -In our case we are using okta with the defualt authorization server so the issuer is `https:///oauth2/default` and the algorithm will be `RS256`. +In our case we are using Okta with the default authorization server so the issuer is `https:///oauth2/default` and the algorithm will be `RS256`. We will use the [`jsonwebtoken`](https://github.com/auth0/node-jsonwebtoken) library to handle the verification. @@ -153,14 +154,15 @@ import jwt from "jsonwebtoken"; import jwksClient from "jwks-rsa"; import { jwtDecode } from "jwt-decode"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // ... (code from step 2-4) ... // Attempt to verify and decode JWT payload. // It will also auto verify exp, ensure system is in sync with time. + // Throws on failure, so any code below runs only on a verified token. try { - await jwt.verify(token, secret, { + jwt.verify(token, secret, { // always validate the issuer issuer: "https:///oauth2/default", // if applicable always validate the audience @@ -191,9 +193,9 @@ export default defineHook(({ filter }, { logger }) => { At this point, the JWT is verified and we can trust its claims. -## 6. Processing the payload +## 6. Processing the Payload -### **Extracting Custom Claims** +### Extracting Custom Claims After verifying the JWT, the payload contains both standard claims and any custom claims your identity provider has added. @@ -205,11 +207,11 @@ Standard claims available in most JWTs: - `exp` - Expiration time - `iat` - Issued at time -Custom claims vary by provider. For example, Auth0 equires URL-namespaced claim names, while Okta allows simple property names. +Custom claims vary by provider. For example, Auth0 requires URL-namespaced claim names, while Okta allows simple property names. -### **Mapping JWT Claims to Directus Accountability** +### Mapping JWT Claims to Directus Accountability -The claims you extract from the JWT—whether standard or custom—determine how you map external identities to Directus users and roles. +The claims you extract from the JWT (whether standard or custom) determine how you map external identities to Directus users and roles. Standard claims (e.g. `sub`, `iss` etc) can be sufficient for basic mappings (e.g., using `sub` as an external identifier), but custom claims provide more flexibility for complex scenarios. By extracting the right claims, you can: @@ -221,15 +223,15 @@ Standard claims (e.g. `sub`, `iss` etc) can be sufficient for basic mappings (e. With the JWT payload verified we can map its claims to a Directus accountability. -Their are multiple ways to achieve this but two common patters exist: +This mapping can be done in several ways. The three most common: -- [Option 1 - Lookup User Dynamically Using a Claim](#option-1---lookup-user-dynamically-using-a-claim) -- [Option 2 - Use a Static Mapping](#option-2--use-a-static-mapping) -- [Option 3 - Auto-Provision Users](#option-3---auto-provision-users) +- [Dynamically via Claim](#dynamically-via-claim) +- [Static Mapping](#static-mapping) +- [Auto-Provisioning](#auto-provisioning) -#### Option 1 - Lookup User Dynamically Using a Claim +#### Dynamically via Claim -We match a directus users `external_identifier` with the JWt's `sub` property, using their information to build the accountability +We match a Directus user's `external_identifier` against the JWT's `sub` claim, then use their information to build the accountability. ```ts import { @@ -245,20 +247,20 @@ import jwksClient from "jwks-rsa"; import { jwtDecode } from "jwt-decode"; import { fetchRolesTree, fetchGlobalAccess } from "@directus/utils/node"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // ... (code from step 2-5) ... // Process validated payload - const { database } = context; + const { database } = eventContext; // Determine user and/or role via external_identifier // Use direct query as user/accountability is not known const user = await database - .select("directus_users.id", "directus_users.role") + .select("id", "role") .from("directus_users") .where({ - "directus_users.external_identifier": payload.sub, + external_identifier: payload.sub, status: "active", }) .first(); @@ -271,7 +273,7 @@ export default defineHook(({ filter }, { logger }) => { // Ensure we do not mutate the original acc const accountability = Object.assign( {}, - defaultAccountability + defaultAccountability, ) as Accountability; accountability.user = user.id; @@ -291,7 +293,7 @@ export default defineHook(({ filter }, { logger }) => { This approach is flexible and works well with automated provisioning. -#### Option 2 — Use a Static Mapping +#### Static Mapping If the mapping between the claim and user id/role is known ahead of time, you can avoid DB queries entirely. @@ -309,12 +311,12 @@ import jwksClient from "jwks-rsa"; import { jwtDecode } from "jwt-decode"; import { fetchRolesTree, fetchGlobalAccess } from "@directus/utils/node"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // ... (code from step 2-5) ... // Process validated payload - const { database } = context; + const { database } = eventContext; // Determine user and role via mapping const mapping = new Map([ @@ -338,7 +340,7 @@ export default defineHook(({ filter }, { logger }) => { // Ensure we do not mutate the original acc const accountability = Object.assign( {}, - defaultAccountability + defaultAccountability, ) as Accountability; accountability.user = user.id; @@ -357,9 +359,9 @@ export default defineHook(({ filter }, { logger }) => { }); ``` -This method is best for small systems or service accounts +This method is best for small systems or service accounts. -#### Option 3 - Auto-Provision Users +#### Auto-Provisioning This method combines user lookup with auto-provisioning for dynamic service account management. @@ -371,13 +373,14 @@ import { TokenExpiredError, } from "@directus/errors"; import { defineHook } from "@directus/extensions-sdk"; +import { Accountability } from "@directus/types"; import jwt from "jsonwebtoken"; import jwksClient from "jwks-rsa"; import { jwtDecode } from "jwt-decode"; import { fetchRolesTree, fetchGlobalAccess } from "@directus/utils/node"; -export default defineHook(({ filter }, { logger }) => { - filter("authenticate", async (defaultAccountability, meta, context) => { +export default defineHook(({ filter }, { logger, services, getSchema }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { // ... (code from step 2-5) ... // Extract custom claims @@ -396,21 +399,26 @@ export default defineHook(({ filter }, { logger }) => { throw new InvalidCredentialsError(); } - const { database, services } = context; + const { database } = eventContext; // Try to find existing user by external_identifier let user = await database - .select("directus_users.id", "directus_users.role") + .select("id", "role") .from("directus_users") .where({ - "directus_users.external_identifier": serviceName, + external_identifier: serviceName, status: "active", }) .first(); // Auto-provision user if doesn't exist if (!user) { - const usersService = new services.UsersService(context); + const usersService = new services.UsersService({ + // eventContext.schema is null during `authenticate`, so we must manually fetch it + schema: await getSchema(), + knex: database, + }); + const userPayload = { external_identifier: serviceName, email: `${serviceName}@service.local`, @@ -429,7 +437,12 @@ export default defineHook(({ filter }, { logger }) => { } // Build accountability - const accountability = Object.assign({}, defaultAccountability); + // Ensure we do not mutate the original acc + const accountability = Object.assign( + {}, + defaultAccountability, + ) as Accountability; + accountability.user = user.id; accountability.role = user.role; accountability.roles = await fetchRolesTree(user.role, { knex: database }); @@ -457,3 +470,144 @@ This guide covers the basics. For a production-ready setup, you may want to: - Load constants (e.g. jwks_uri, issuer etc) from environment variables. - Cache the JWK response according to its expected rotation interval. - Support multiple identity providers. + +## Full Example + +The snippet below combines every step into a single, copy-ready `index.ts` using the [Dynamically via Claim](#dynamically-via-claim) mapping strategy. Replace `` with your own domain, and swap the mapping section for [Static Mapping](#static-mapping) or [Auto-Provisioning](#auto-provisioning) if they fit your use case better. + +```ts +import { + InvalidCredentialsError, + InvalidTokenError, + ServiceUnavailableError, + TokenExpiredError, +} from "@directus/errors"; +import { defineHook } from "@directus/extensions-sdk"; +import { Accountability } from "@directus/types"; +import { fetchRolesTree, fetchGlobalAccess } from "@directus/utils/node"; +import jwt from "jsonwebtoken"; +import jwksClient from "jwks-rsa"; +import { jwtDecode } from "jwt-decode"; + +export default defineHook(({ filter }, { logger }) => { + filter("authenticate", async (defaultAccountability, meta, eventContext) => { + // 1. Access the third-party JWT (sent as a Bearer token) + const token = meta.req.token; + + if (!token) return; + + // 2. Decode the payload (unverified) for inspection + let payload; + try { + payload = jwtDecode(token); + } catch (error) { + logger.error(error); + // Invalid JWT, let Directus resume its normal flow + return; + } + + // Ignore Directus-issued tokens + if (payload.iss === "directus") return; + + // 3. Read the `kid` header to identify the signing key + let headers; + try { + headers = jwtDecode(token, { header: true }); + } catch (error) { + logger.error(error); + return; + } + + const kid = headers.kid; + + if (!kid) { + logger.error("'kid' is required"); + throw new InvalidTokenError(); + } + + // 4. Fetch the signing key from the provider's JWKS endpoint + const client = jwksClient({ + jwksUri: "https:///oauth2/default/v1/keys", + }); + + let secret; + try { + const key = await client.getSigningKey(kid); + secret = key.getPublicKey(); + } catch (error) { + logger.error(error); + throw new InvalidCredentialsError(); + } + + if (!secret) { + logger.error("'secret' is required"); + throw new InvalidCredentialsError(); + } + + // 5. Verify the token signature and claims. + // Throws on failure, so any code below runs only on a verified token. + try { + jwt.verify(token, secret, { + // always validate the issuer + issuer: "https:///oauth2/default", + // if applicable always validate the audience + // audience: "directus", + // always restrict to the algorithms you need + algorithms: ["RS256"], + }); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new TokenExpiredError(); + } + if (error instanceof jwt.JsonWebTokenError) { + throw new InvalidTokenError(); + } + throw new ServiceUnavailableError({ + service: "jwt", + reason: `Couldn't verify token.`, + }); + } + + // Payload is expected to be an object + if (typeof payload === "string") { + throw new InvalidTokenError(); + } + + // 6. Map the verified claims to a Directus Accountability object + const { database } = eventContext; + + // Look up the user via their external_identifier + const user = await database + .select("id", "role") + .from("directus_users") + .where({ + external_identifier: payload.sub, + status: "active", + }) + .first(); + + if (!user) { + throw new InvalidCredentialsError(); + } + + // Avoid mutating the original accountability + const accountability = Object.assign( + {}, + defaultAccountability, + ) as Accountability; + + accountability.user = user.id; + accountability.role = user.role; + accountability.roles = await fetchRolesTree(user.role, { knex: database }); + + const { admin, app } = await fetchGlobalAccess(accountability, { + knex: database, + }); + + accountability.admin = admin; + accountability.app = app; + + return accountability; + }); +}); +```