From 2209ffe20446832b7bbc120fab2965c542d83131 Mon Sep 17 00:00:00 2001 From: "Bryson G." <114206517+bryson-g@users.noreply.github.com> Date: Wed, 22 Jan 2025 10:11:42 -0800 Subject: [PATCH] feat: env var authorities config (#63) * feat: env var authorities config * chore: unit tests for getRoles * refactor: rename env config --- README.md | 28 ++++--- ui/.env.sample | 6 +- ui/package.json | 3 +- .../app/api/auth/[...nextauth]/authOptions.ts | 3 +- ui/src/lib/utils.test.ts | 82 +++++++++++++++++++ ui/src/lib/utils.ts | 25 ++++++ ui/src/middleware.ts | 21 +---- 7 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 ui/src/lib/utils.test.ts diff --git a/README.md b/README.md index a05495b..c2d3db4 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ To access the Keycloak admin console at , use: You can log in to the User Tasks UI at with these pre-configured users: | User | Password | Role | -|---------------|----------|-------| +| ------------- | -------- | ----- | | my-admin-user | 1234 | Admin | | my-user | 1234 | User | @@ -131,11 +131,13 @@ AUTH_URL='http://localhost:3000' NEXTAUTH_URL='http://localhost:3000' AUTH_SECRET='' -AUTH_KEYCLOAK_ID='sso-workflow-bridge-client' +AUTH_KEYCLOAK_ID='user-tasks-client' AUTH_KEYCLOAK_SECRET=' ' AUTH_KEYCLOAK_ISSUER='http://localhost:8888/realms/default' LHUT_API_URL='http://localhost:8089' + +AUTHORITIES='$.realm_access.roles,$.resource_access.*.roles' ``` 1. Install dependencies and start development server: @@ -190,10 +192,11 @@ docker run --rm \ -e AUTH_URL='https://localhost:3443' \ -e NEXTAUTH_URL='https://localhost:3443' \ -e AUTH_SECRET='your-secret-here' \ - -e AUTH_KEYCLOAK_ID='sso-workflow-bridge-client' \ + -e AUTH_KEYCLOAK_ID='user-tasks-client' \ -e AUTH_KEYCLOAK_SECRET=' ' \ -e AUTH_KEYCLOAK_ISSUER='http://localhost:8888/realms/default' \ -e LHUT_API_URL='http://localhost:8089' \ + -e AUTHORITIES='$.realm_access.roles,$.resource_access.*.roles' \ -p 3000:3000 -p 3443:3443 \ ghcr.io/littlehorse-enterprises/lh-sso-workflow-bridge/lh-sso-workflow-bridge-ui:main ``` @@ -205,15 +208,16 @@ When SSL is enabled, the UI will be available on: ### Environment Variables for SSL -| Variable | Description | Required | -|----------|-------------|----------| -| `SSL` | Set to `enabled` to enable SSL | Yes | -| `AUTH_URL` | Full URL where the app will be accessible (use HTTPS port) | Yes | -| `AUTH_SECRET` | Random string used to hash tokens | Yes | -| `AUTH_KEYCLOAK_ID` | Client ID from Keycloak | Yes | -| `AUTH_KEYCLOAK_SECRET` | Client secret from Keycloak | Yes | -| `AUTH_KEYCLOAK_ISSUER` | Keycloak server URL | Yes | -| `LHUT_API_URL` | URL of the User Tasks API | Yes | +| Variable | Description | Required | +| ---------------------- | ---------------------------------------------------------- | -------- | +| `SSL` | Set to `enabled` to enable SSL | Yes | +| `AUTH_URL` | Full URL where the app will be accessible (use HTTPS port) | Yes | +| `AUTH_SECRET` | Random string used to hash tokens | Yes | +| `AUTH_KEYCLOAK_ID` | Client ID from Keycloak | Yes | +| `AUTH_KEYCLOAK_SECRET` | Client secret from Keycloak | Yes | +| `AUTH_KEYCLOAK_ISSUER` | Keycloak server URL | Yes | +| `LHUT_API_URL` | URL of the User Tasks API | Yes | +| `AUTHORITIES` | Paths to extract roles from the token | Yes | ### Notes diff --git a/ui/.env.sample b/ui/.env.sample index fdc99c7..c3ede61 100644 --- a/ui/.env.sample +++ b/ui/.env.sample @@ -2,8 +2,10 @@ AUTH_URL='http://localhost:3000' NEXTAUTH_URL='http://localhost:3000' AUTH_SECRET='' -AUTH_KEYCLOAK_ID='sso-workflow-bridge-client' +AUTH_KEYCLOAK_CLIENT_ID='user-tasks-client' AUTH_KEYCLOAK_SECRET=' ' AUTH_KEYCLOAK_ISSUER='http://localhost:8888/realms/default' -LHUT_API_URL='http://localhost:8089' \ No newline at end of file +LHUT_API_URL='http://localhost:8089' + +AUTHORITIES=$.realm_access.roles,$.resource_access.*.roles diff --git a/ui/package.json b/ui/package.json index a087c10..9e261ca 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,7 +5,8 @@ "build": "next build", "start": "next start", "lint": "prettier --check .", - "lint:fix": "prettier --write ." + "lint:fix": "prettier --write .", + "test": "npx tsx --test" }, "dependencies": { "@hookform/resolvers": "^3.10.0", diff --git a/ui/src/app/api/auth/[...nextauth]/authOptions.ts b/ui/src/app/api/auth/[...nextauth]/authOptions.ts index b0b1a7e..b278c96 100644 --- a/ui/src/app/api/auth/[...nextauth]/authOptions.ts +++ b/ui/src/app/api/auth/[...nextauth]/authOptions.ts @@ -1,3 +1,4 @@ +import { getRoles } from "@/lib/utils"; import { jwtDecode } from "jwt-decode"; import { GetServerSidePropsContext, @@ -37,7 +38,7 @@ export const authOptions: NextAuthOptions = { async session({ session, token }: any) { session.access_token = token.access_token; session.id_token = token.id_token; - session.roles = token.decoded.realm_access.roles; + session.roles = getRoles(token.decoded); session.error = token.error; session.user = { ...session.user, id: token.decoded.sub }; return session; diff --git a/ui/src/lib/utils.test.ts b/ui/src/lib/utils.test.ts new file mode 100644 index 0000000..dd14b6d --- /dev/null +++ b/ui/src/lib/utils.test.ts @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { getRoles } from "./utils"; + +test("getRoles with roles and permissions", () => { + process.env.AUTHORITIES = "$.realm_access.roles,$.resource_access.*.roles"; + + const testObj = { + realm_access: { + roles: ["admin", "editor"], + }, + resource_access: { + admin: { + roles: ["read", "write"], + }, + }, + }; + + const expectedRoles = ["admin", "editor", "read", "write"]; + const result = getRoles(testObj); + assert.deepStrictEqual( + result, + expectedRoles, + "Should return all roles and permissions", + ); +}); + +test("getRoles with one matching path (no wildcard)", () => { + process.env.AUTHORITIES = "$.realm_access.roles,$.resource_access.*.roles"; + + const testObj = { + realm_access: { + roles: ["admin", "editor"], + }, + }; + + const expectedRoles = ["admin", "editor"]; + const result = getRoles(testObj); + assert.deepStrictEqual( + result, + expectedRoles, + "Should return roles from the matching path", + ); +}); + +test("getRoles with one matching path (with wildcard)", () => { + process.env.AUTHORITIES = "$.realm_access.roles,$.resource_access.*.roles"; + + const testObj = { + resource_access: { + admin: { + roles: ["read", "write"], + }, + }, + }; + + const expectedRoles = ["read", "write"]; + const result = getRoles(testObj); + assert.deepStrictEqual( + result, + expectedRoles, + "Should return roles from the matching path", + ); +}); + +test("getRoles with no matching paths", () => { + process.env.AUTHORITIES = "$.realm_access.roles,$.resource_access.*.roles"; + + const obj = { + guest: { + access: ["view"], + }, + }; + + const expectedRoles: any[] = []; + const result = getRoles(obj); + assert.deepStrictEqual( + result, + expectedRoles, + "Should return an empty array for no matching paths", + ); +}); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index a5ef193..7686e0d 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -4,3 +4,28 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function getRoles(obj: any) { + const splitPaths = process.env.AUTHORITIES?.split(",") ?? []; + const paths = splitPaths.map((path) => path.split(".").slice(1)); + + const roles = paths.flatMap((pathArray) => { + return pathArray.reduce( + (current, key) => { + if (key === "*") { + return current.flatMap((item) => { + if (typeof item === "object" && item !== null) { + return Object.values(item); + } + return []; + }); + } else { + return current.flatMap((item) => item[key]).filter(Boolean); + } + }, + [obj], + ); + }); + + return roles; +} diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts index a101bbe..c68a9a1 100644 --- a/ui/src/middleware.ts +++ b/ui/src/middleware.ts @@ -1,6 +1,7 @@ import { getToken } from "next-auth/jwt"; import nextAuth from "next-auth/middleware"; import { NextResponse } from "next/server"; +import { getRoles } from "./lib/utils"; const withAuth = nextAuth(async (req) => { const token = await getToken({ req, secret: process.env.AUTH_SECRET }); @@ -12,25 +13,9 @@ const withAuth = nextAuth(async (req) => { ); } - // Call keycloak to check if token is valid - const keycloakResponse = await fetch( - `${process.env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/userinfo`, - { - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - }, - ); - - if (!keycloakResponse.ok) { - return NextResponse.redirect( - `${baseUrl}/api/auth/signin?callbackUrl=${currentPath}`, - ); - } - // Redirect to tenant after login if (currentPath === "/" && token.decoded.allowed_tenant) { - if (token.decoded.realm_access.roles.includes("lh-user-tasks-admin")) + if (getRoles(token.decoded).includes("lh-user-tasks-admin")) return NextResponse.redirect( `${baseUrl}/${token.decoded.allowed_tenant}/admin`, ); @@ -47,7 +32,7 @@ const withAuth = nextAuth(async (req) => { // Check if current path is admin and user is not admin if ( currentPath.includes("/admin") && - !token.decoded.realm_access.roles.includes("lh-user-tasks-admin") + !getRoles(token.decoded).includes("lh-user-tasks-admin") ) { return NextResponse.redirect(`${baseUrl}/${token.decoded.allowed_tenant}`); }