From 311e5b0ed002a0e3698effd7ae782f860175b4b3 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Wed, 10 Jun 2026 05:57:25 -0700 Subject: [PATCH] fix: add roles gating the access of frontend ui --- dev-tools/kind/README.md | 2 +- .../kind/config/structures-server/values.yaml | 4 +- dev-tools/kind/kind-cluster.sh | 2 +- docker-compose/compose.yml | 4 +- docs/kubernetes/KIND_TOOLING.md | 2 +- gradle.properties | 2 +- helm/structures/Chart.yaml | 2 +- helm/structures/values.yaml | 4 +- structures-auth/README.md | 83 +++++++++++++++++-- .../auth/api/domain/OidcProvider.java | 11 +++ structures-frontend/CONFIGURATION.md | 3 + structures-frontend/package.json | 2 +- structures-frontend/src/states/IUserState.ts | 16 ++++ structures-frontend/src/util/config.ts | 1 + .../docker-compose/cluster-test-compose.yml | 6 +- 15 files changed, 123 insertions(+), 21 deletions(-) diff --git a/dev-tools/kind/README.md b/dev-tools/kind/README.md index bc73a1ed..e7c48f28 100644 --- a/dev-tools/kind/README.md +++ b/dev-tools/kind/README.md @@ -249,7 +249,7 @@ Load a locally built Docker image into KinD cluster nodes. ./kind-cluster.sh load # Load specific image -./kind-cluster.sh load --image mindsignited/structures-server:3.5.6 +./kind-cluster.sh load --image mindsignited/structures-server:3.5.7 # Load into specific cluster ./kind-cluster.sh load --name test-cluster diff --git a/dev-tools/kind/config/structures-server/values.yaml b/dev-tools/kind/config/structures-server/values.yaml index 0bd136aa..f6da89e2 100644 --- a/dev-tools/kind/config/structures-server/values.yaml +++ b/dev-tools/kind/config/structures-server/values.yaml @@ -33,7 +33,7 @@ image: # Repository matches bootBuildImage output repository: mindsignited/structures-server # Tag from gradle.properties version (structuresVersion=3.5.3-SNAPSHOT) - tag: 3.5.6 + tag: 3.5.7 # Never pull - use images loaded into KinD cluster pullPolicy: Never sha: "" @@ -46,7 +46,7 @@ migration: activeDeadlineSeconds: 300 image: repository: mindsignited/structures-migration - tag: 3.5.6 + tag: 3.5.7 # Never pull - use images loaded into KinD cluster pullPolicy: Never sha: "" diff --git a/dev-tools/kind/kind-cluster.sh b/dev-tools/kind/kind-cluster.sh index 65e7084b..0e697905 100755 --- a/dev-tools/kind/kind-cluster.sh +++ b/dev-tools/kind/kind-cluster.sh @@ -728,7 +728,7 @@ Examples: $(basename "$0") load # Load specific image - $(basename "$0") load --image mindsignited/structures-server:3.5.6 + $(basename "$0") load --image mindsignited/structures-server:3.5.7 # Load into specific cluster $(basename "$0") load --name test-cluster diff --git a/docker-compose/compose.yml b/docker-compose/compose.yml index c6f48068..8e55f6e3 100644 --- a/docker-compose/compose.yml +++ b/docker-compose/compose.yml @@ -6,7 +6,7 @@ services: structures-migration: container_name: structures-migration pull_policy: always - image: mindsignited/structures-migration:${structuresVersion:-3.5.6} + image: mindsignited/structures-migration:${structuresVersion:-3.5.7} depends_on: structures-elasticsearch: condition: service_healthy @@ -17,7 +17,7 @@ services: structures-server: container_name: structures-server pull_policy: always - image: mindsignited/structures-server:${structuresVersion:-3.5.6} + image: mindsignited/structures-server:${structuresVersion:-3.5.7} depends_on: structures-elasticsearch: condition: service_healthy diff --git a/docs/kubernetes/KIND_TOOLING.md b/docs/kubernetes/KIND_TOOLING.md index fd22f0c2..903764f2 100644 --- a/docs/kubernetes/KIND_TOOLING.md +++ b/docs/kubernetes/KIND_TOOLING.md @@ -300,7 +300,7 @@ Load a locally built Docker image into KinD cluster nodes. ./kind-cluster.sh load # Load specific image -./kind-cluster.sh load --image mindsignited/structures-server:3.5.6 +./kind-cluster.sh load --image mindsignited/structures-server:3.5.7 # Load into specific cluster ./kind-cluster.sh load --name test-cluster diff --git a/gradle.properties b/gradle.properties index f8206341..ef2f06f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -structuresVersion=3.5.6 +structuresVersion=3.5.7 allureVersion=2.32.0 antlrVersion=4.13.1 diff --git a/helm/structures/Chart.yaml b/helm/structures/Chart.yaml index c83658c1..7f3d3beb 100644 --- a/helm/structures/Chart.yaml +++ b/helm/structures/Chart.yaml @@ -12,4 +12,4 @@ version: 1.0.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "3.5.6" +appVersion: "3.5.7" diff --git a/helm/structures/values.yaml b/helm/structures/values.yaml index 920cc906..24133441 100644 --- a/helm/structures/values.yaml +++ b/helm/structures/values.yaml @@ -4,7 +4,7 @@ nameOverride: "" image: repository: mindsignited/structures-server pullPolicy: Always - tag: "3.5.6" + tag: "3.5.7" sha: "" # Migration configuration @@ -15,7 +15,7 @@ migration: activeDeadlineSeconds: 300 image: repository: mindsignited/structures-migration - tag: "3.5.6" + tag: "3.5.7" pullPolicy: Always sha: "" diff --git a/structures-auth/README.md b/structures-auth/README.md index ac4d4724..44d8ef90 100644 --- a/structures-auth/README.md +++ b/structures-auth/README.md @@ -167,11 +167,13 @@ oidc-security-service: | `redirect-uri` | string | Yes | OAuth redirect URI after authentication | | `post-logout-redirect-uri` | string | Yes | Redirect URI after logout | | `silent-redirect-uri` | string | Yes | URI for silent token renewal | -| `domains` | array | Yes | Email domains this provider handles | +| `domains` | array | No* | Email domains this provider handles. *Required unless `allow-any-domain: true`. | +| `allow-any-domain` | boolean | No | If `true`, this provider matches on issuer alone and skips the email-domain check. Required for M2M tokens (no email claim, `sub` is a client id) and useful when the IDP issues tokens for users from arbitrary email domains. A provider whose `domains` matches a token's email domain is preferred over an `allow-any-domain` provider with the same issuer. Default: `false`. | | `audience` | string | Yes | Expected audience claim in JWT tokens | | `roles-claim-path` | string | No | JSON path to roles claim in JWT | | `additional-scopes` | string | No | Additional OAuth scopes to request | -| `roles` | array | No | Default roles for this provider | +| `roles` | array | No | Roles required by the backend authorization flow. If set, the token must contain at least one of these in the claim at `roles-claim-path`. | +| `front-end-roles` | array | No | Roles required to access the **frontend** application via this provider. **Not used by backend authorization** — the backend accepts any token that satisfies `roles` (or has none required). The frontend enforces this list after login by intersecting it with the participant's roles; users with no matching role are rejected at the UI. Leave empty/unset to impose no additional frontend gate. | | `metadata` | object | No | Additional provider metadata | ## Security Considerations @@ -209,20 +211,32 @@ The library includes comprehensive tests: ### 2. **Token Validation** - Validates JWT signature using JWKS from the issuer -- Verifies issuer against configured OIDC providers +- Resolves a matching provider for the token (see **Provider Matching** below) - Checks audience claims against provider configuration - Validates token expiration +- If `roles-claim-path` is set, extracts roles and (if `roles` is set on the provider) requires at least one match -### 3. **User Creation** +### 3. **Provider Matching** +For each token, an enabled provider is selected as follows: +1. Filter providers whose `authority` equals the token's `iss` claim. +2. The matcher looks for an email-formatted value across the `email`, `preferred_username`, `sub`, `upn`, and `unique_name` claims. +3. If an email was found, a provider whose `domains` contains the email's domain wins. +4. Otherwise, fall back to any matching provider with `allow-any-domain: true`. +5. If the token has no email and no candidate has `allow-any-domain: true`, the token is rejected. + +This lets a single Okta tenant (one `authority`) split across multiple providers — e.g. a per-customer config with explicit `domains` plus a catchall with `allow-any-domain: true` for M2M tokens and arbitrary-domain users. + +### 4. **User Creation** - Creates `Participant` objects from JWT claims - Extracts user information (email, name, roles) -- Maps email domains to appropriate OIDC providers +- Email is omitted from metadata when not present in the token (M2M case) - Applies role-based access control -### 4. **Frontend Integration** +### 5. **Frontend Integration** - Serves configuration overrides at `/app-config.override.json` - Enables dynamic frontend configuration without rebuilds - Supports runtime provider enable/disable +- `front-end-roles` is shipped to the frontend in the config payload. After login, the frontend intersects this list with the participant's roles and rejects users with no overlap — the backend already accepted the token, but the UI app stays gated behind a role check. ## Examples @@ -264,6 +278,63 @@ oidc-security-service: roles-claim-path: "roles" ``` +### Okta with Arbitrary Email Domains + Frontend Role Gate + +Use this shape when the IDP issues tokens for customers from any email domain +and you still want to restrict who can access the frontend. The `domains` entry +is what the frontend uses for SSO discovery; the backend accepts any domain +because `allow-any-domain` is `true`. + +```yaml +oidc-security-service: + enabled: true + oidc-providers: + - provider: "okta-customers" + display-name: "Okta" + enabled: true + client-id: "0oaowrlsm5Ua1vWD85d7" + authority: "https://acme.okta.com/oauth2/default" + redirect-uri: "http://localhost:5173/login" + post-logout-redirect-uri: "http://localhost:5173" + silent-redirect-uri: "http://localhost:5173/login/silent-renew" + audience: "0oaowrlsm5Ua1vWD85d7" + domains: + - "acme.com" + allow-any-domain: true + roles-claim-path: "roles" + front-end-roles: + - "structures-user" + - "structures-admin" +``` + +### Okta M2M (Machine-to-Machine) Tokens + +M2M tokens from Okta typically have no `email` claim and a `sub` that is the +client id. Set `allow-any-domain: true` so the matcher doesn't try to validate +an email domain, and use `roles` (combined with `roles-claim-path`) to gate on +the scope/role assigned to the client. Often configured as a *second* provider +under the same `authority` as your interactive provider, with a different +audience. + +```yaml +oidc-security-service: + enabled: true + oidc-providers: + - provider: "okta-m2m" + display-name: "Okta M2M" + enabled: true + client-id: "0oaowrlsm5Ua1vWD85d7" + authority: "https://acme.okta.com/oauth2/default" + redirect-uri: "" + post-logout-redirect-uri: "" + silent-redirect-uri: "" + audience: "api://internal-m2m" + allow-any-domain: true + roles-claim-path: "scp" + roles: + - "m2m-service" +``` + ## Related Documentation For detailed OIDC configuration and troubleshooting, see: diff --git a/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java b/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java index 6c273e40..44890e1f 100644 --- a/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java +++ b/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java @@ -82,6 +82,17 @@ public class OidcProvider { */ private List roles; + /** + * Roles required to access the frontend application via this provider. + * NOT used by the backend authorization flow — the backend will still + * authenticate any token that satisfies {@link #roles} (or has none required). + * This list is shipped to the frontend in the app config and the frontend + * enforces it after login by checking the participant's roles against this + * list; users with no matching role are rejected at the UI. + * If null or empty, the frontend imposes no additional role gate. + */ + private List frontEndRoles; + /** * Any additional metadata of the OIDC provider, will be added to the Participant metadata. */ diff --git a/structures-frontend/CONFIGURATION.md b/structures-frontend/CONFIGURATION.md index 3ec12d3b..6ecd7b18 100644 --- a/structures-frontend/CONFIGURATION.md +++ b/structures-frontend/CONFIGURATION.md @@ -111,6 +111,9 @@ Each OIDC provider has the following configuration options: - `redirect_uri`: The redirect URI after successful authentication - `post_logout_redirect_uri`: The redirect URI after logout - `silent_redirect_uri`: The redirect URI for silent token renewal +- `domains`: Array of email domains this provider handles. Used by the login UI for SSO discovery and by the backend to match tokens to providers. +- `allowAnyDomain`: If `true`, this provider matches on issuer alone and the backend skips the email-domain check. Required for M2M tokens (no email claim) and useful when the IDP issues tokens for users from arbitrary email domains. Default: `false`. +- `frontEndRoles`: Array of roles required to access the frontend application via this provider. The backend ignores this list — it is enforced by the frontend after login by intersecting it with the participant's roles. Users with no matching role are signed out at the UI. Leave empty/unset for no frontend role gate. ### Microsoft-specific Options diff --git a/structures-frontend/package.json b/structures-frontend/package.json index f84afe79..33a30a8f 100644 --- a/structures-frontend/package.json +++ b/structures-frontend/package.json @@ -1,6 +1,6 @@ { "name": "structures-frontend-next", - "version": "3.5.6", + "version": "3.5.7", "type": "module", "scripts": { "dev": "vite", diff --git a/structures-frontend/src/states/IUserState.ts b/structures-frontend/src/states/IUserState.ts index 154e7e9d..8117e4a3 100644 --- a/structures-frontend/src/states/IUserState.ts +++ b/structures-frontend/src/states/IUserState.ts @@ -6,6 +6,7 @@ import { createDebug } from '@/util/debug' const debug = createDebug('user-state'); import { oidcSessionManager } from '@/util/OidcSessionManager' +import { configService } from '@/util/config' import { createConnectionInfo } from '../util/helpers' export interface IUserState { @@ -76,6 +77,21 @@ export class UserState implements IUserState { try { this.connectedInfo = await Continuum.connect(connectionInfo) + + // Frontend role gate. The backend has already validated the token; this is a + // UI-only admission check using frontEndRoles configured on the OIDC provider. + const providerConfig = await configService.getOidcProviderByName(provider) + if (providerConfig?.frontEndRoles && providerConfig.frontEndRoles.length > 0) { + const userRoles = this.connectedInfo.participant.roles ?? [] + const hasRequiredRole = providerConfig.frontEndRoles.some(r => userRoles.includes(r)) + if (!hasRequiredRole) { + try { await Continuum.disconnect() } catch { /* best effort */ } + this.connectedInfo = null + this.accessDenied = true + throw new Error(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`) + } + } + this.authenticated = true this.accessDenied = false this.oidcUser = user diff --git a/structures-frontend/src/util/config.ts b/structures-frontend/src/util/config.ts index 4d25998d..540f8b44 100644 --- a/structures-frontend/src/util/config.ts +++ b/structures-frontend/src/util/config.ts @@ -17,6 +17,7 @@ interface OidcProvider { audience?: string; domains?: string[]; roles?: string[]; + frontEndRoles?: string[]; rolesClaimPath?: string; additionalScopes?: string; metadata?: Record; diff --git a/structures-test/src/test/resources/docker-compose/cluster-test-compose.yml b/structures-test/src/test/resources/docker-compose/cluster-test-compose.yml index 9ff6186d..cd9d2288 100644 --- a/structures-test/src/test/resources/docker-compose/cluster-test-compose.yml +++ b/structures-test/src/test/resources/docker-compose/cluster-test-compose.yml @@ -1,7 +1,7 @@ services: structures-node1: - image: docker.io/mindsignited/structures-server:3.5.6 + image: docker.io/mindsignited/structures-server:3.5.7 healthcheck: test: ["CMD", "/workspace/health-check"] interval: 10s @@ -90,7 +90,7 @@ services: memory: 4G structures-node2: - image: docker.io/mindsignited/structures-server:3.5.6 + image: docker.io/mindsignited/structures-server:3.5.7 healthcheck: test: ["CMD", "/workspace/health-check"] interval: 10s @@ -178,7 +178,7 @@ services: memory: 4G structures-node3: - image: docker.io/mindsignited/structures-server:3.5.6 + image: docker.io/mindsignited/structures-server:3.5.7 healthcheck: test: ["CMD", "/workspace/health-check"] interval: 10s