Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev-tools/kind/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions dev-tools/kind/config/structures-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand All @@ -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: ""
Expand Down
2 changes: 1 addition & 1 deletion dev-tools/kind/kind-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docker-compose/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/kubernetes/KIND_TOOLING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
structuresVersion=3.5.6
structuresVersion=3.5.7

allureVersion=2.32.0
antlrVersion=4.13.1
Expand Down
2 changes: 1 addition & 1 deletion helm/structures/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions helm/structures/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ nameOverride: ""
image:
repository: mindsignited/structures-server
pullPolicy: Always
tag: "3.5.6"
tag: "3.5.7"
sha: ""

# Migration configuration
Expand All @@ -15,7 +15,7 @@ migration:
activeDeadlineSeconds: 300
image:
repository: mindsignited/structures-migration
tag: "3.5.6"
tag: "3.5.7"
pullPolicy: Always
sha: ""

Expand Down
83 changes: 77 additions & 6 deletions structures-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ public class OidcProvider {
*/
private List<String> 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<String> frontEndRoles;

/**
* Any additional metadata of the OIDC provider, will be added to the Participant metadata.
*/
Expand Down
3 changes: 3 additions & 0 deletions structures-frontend/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion structures-frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "structures-frontend-next",
"version": "3.5.6",
"version": "3.5.7",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
16 changes: 16 additions & 0 deletions structures-frontend/src/states/IUserState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions structures-frontend/src/util/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface OidcProvider {
audience?: string;
domains?: string[];
roles?: string[];
frontEndRoles?: string[];
rolesClaimPath?: string;
additionalScopes?: string;
metadata?: Record<string, string>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading