Skip to content

Commit

Permalink
feat: env var authorities config (#63)
Browse files Browse the repository at this point in the history
* feat: env var authorities config

* chore: unit tests for getRoles

* refactor: rename env config
  • Loading branch information
bryson-g authored Jan 22, 2025
1 parent 43f1a73 commit 2209ffe
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 34 deletions.
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ To access the Keycloak admin console at <http://localhost:8888>, use:
You can log in to the User Tasks UI at <http://localhost:3000> with these pre-configured users:

| User | Password | Role |
|---------------|----------|-------|
| ------------- | -------- | ----- |
| my-admin-user | 1234 | Admin |
| my-user | 1234 | User |

Expand Down Expand Up @@ -131,11 +131,13 @@ AUTH_URL='http://localhost:3000'
NEXTAUTH_URL='http://localhost:3000'
AUTH_SECRET='<any secret here>'

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:
Expand Down Expand Up @@ -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
```
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions ui/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ AUTH_URL='http://localhost:3000'
NEXTAUTH_URL='http://localhost:3000'
AUTH_SECRET='<any secret here>'

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'
LHUT_API_URL='http://localhost:8089'

AUTHORITIES=$.realm_access.roles,$.resource_access.*.roles
3 changes: 2 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion ui/src/app/api/auth/[...nextauth]/authOptions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getRoles } from "@/lib/utils";
import { jwtDecode } from "jwt-decode";
import {
GetServerSidePropsContext,
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions ui/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
25 changes: 25 additions & 0 deletions ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
21 changes: 3 additions & 18 deletions ui/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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`,
);
Expand All @@ -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}`);
}
Expand Down

0 comments on commit 2209ffe

Please sign in to comment.