Post-render Scripts¶
Overview¶
Post-render scripts let you run a short Go program after claim mappings (OIDC) or attribute mappings (SAML) have been evaluated. The script receives the current claims/attributes and can:
- Patch them — add new claims or override existing ones
- Delete a claim — return it with an empty string value (
"") - Deny access — return a non-nil error (unless
script_fail_openis enabled) - Query the IdP — look up users, groups, or attributes via the read-only
idppackage - Call external APIs — use
net/httpfor external policy engines
Scripts run in a sandboxed Yaegi interpreter with a restricted standard library and a configurable timeout (1–10 s, default 3 s).
Script contract¶
Scripts must declare package claim and export a single entry point:
- The returned map patches the existing claims. Each key/value pair is merged into the outgoing token:
- A non-empty value adds or replaces the claim.
- An empty string value (
"") deletes the claim from the token. - Returning
nilor an empty map leaves all claims unchanged.
- A non-nil error denies the login. The error message is displayed to the user on a branded error page (HTML) and is also recorded in the audit log.
Configuration¶
| Field | Default | Description |
|---|---|---|
claim_script (OIDC) / attribute_script (SAML) |
"" |
Go source code for the script. Empty string disables the hook. |
script_timeout_secs |
3 |
Maximum execution time in seconds. Clamped to 1–10. |
script_fail_open |
false |
When true, a script error or timeout does not block login — the unmodified claims are used instead. |
Required permissions¶
Access to post-render scripts is controlled by two granular permissions that can be assigned via Admin Roles independently from general application management:
| Permission | Effect |
|---|---|
apps.scripts.view |
The Post-render Script section is visible in read-only mode. Requires apps.view. |
apps.scripts.update |
Can write, modify, or clear scripts. Requires apps.update. |
Users who hold apps.update but neither script permission can still edit all other application settings. The script section is hidden from their view and the existing script is silently preserved on every save — they cannot accidentally clear a script they cannot see.
Adding a script¶
UI: Administration → Applications → (select app) → Post-render Script section
API:
PUT /api/v1/applications/{id}
{
"claim_script": "package claim\nfunc Evaluate() (map[string]interface{}, error) {\n return map[string]interface{}{\"role\": \"viewer\"}, nil\n}",
"script_timeout_secs": 5,
"script_fail_open": false
}
For SAML:
PUT /api/v1/saml/{app_id}/config
{
"attribute_script": "...",
"script_timeout_secs": 5,
"script_fail_open": false
}
Injected packages¶
secrets — Encrypted script secrets¶
Secrets are encrypted at rest (AES-256-GCM). Manage them in the application settings panel under Script Secrets, or include a script_secrets map in the application create/update API request. Secret values are never returned by the API — only key names are exposed.
request — HTTP context at login time¶
import "request"
referer := request.Referer() // HTTP Referer at /authorize (OIDC) or SSO endpoint (SAML)
loginIP := request.LoginIP() // Client IP at /authorize or SSO time
tokenIP := request.TokenIP() // Always empty — script runs at /authorize time for OIDC; reserved for future use
user — Authenticated user¶
import "user"
id := user.ID()
email := user.Email()
username := user.Username()
fn := user.FirstName()
ln := user.LastName()
groups := user.Groups() // []string
attrs := user.Attributes() // map[string]string
mappings := user.AppMappings() // map[string][]string
claims — Read-only view of existing claims/attributes¶
import "claims"
val := claims.Get("sub") // interface{}
str := claims.GetString("email") // string
list := claims.GetList("groups") // []string
ok := claims.Has("custom_claim") // bool
all := claims.All() // map[string]interface{}
idp — Read-only IdP queries¶
import "idp"
u, err := idp.GetUserByEmail("alice@example.com") // *idp.User
g, err := idp.GetGroupByName("admins") // *idp.Group
members, err := idp.GetGroupMembers(g.ID) // []idp.User
attrs, err := idp.GetUserAttributes(u.ID) // map[string]string
Allowed standard library packages¶
Scripts may import the following packages. All other packages (including os, os/exec, syscall, unsafe, runtime, reflect, plugin) are blocked.
| Package | Notes |
|---|---|
secrets |
Injected — access application script secrets |
bytes |
|
encoding/json |
|
errors |
|
fmt |
|
io |
|
math, math/rand |
|
net/http |
Outbound HTTP — be aware of SSRF risk |
net/url |
|
regexp |
|
sort |
|
strconv |
|
strings |
|
time |
|
unicode, unicode/utf8 |
Example scripts¶
Add a static role¶
package claim
func Evaluate() (map[string]interface{}, error) {
return map[string]interface{}{"role": "viewer"}, nil
}
Deny login outside business hours¶
package claim
import (
"fmt"
"time"
)
func Evaluate() (map[string]interface{}, error) {
h := time.Now().UTC().Hour()
if h < 8 || h >= 18 {
return nil, fmt.Errorf("logins only permitted 08:00–18:00 UTC")
}
return nil, nil
}
Remove a claim based on the referrer¶
package claim
import (
"claims"
"request"
"strings"
)
func Evaluate() (map[string]interface{}, error) {
if !strings.HasPrefix(request.Referer(), "https://internal.corp/") {
// strip sensitive claim for external logins
return map[string]interface{}{"internal_id": ""}, nil
}
return nil, nil
}
Enrich with an external policy engine¶
package claim
import (
"encoding/json"
"fmt"
"net/http"
"user"
)
func Evaluate() (map[string]interface{}, error) {
resp, err := http.Get("https://policy.internal/roles?email=" + user.Email())
if err != nil {
return nil, fmt.Errorf("policy check failed: %w", err)
}
defer resp.Body.Close()
var result struct{ Role string }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return map[string]interface{}{"role": result.Role}, nil
}
script_fail_open¶
When script_fail_open is false (the default), any error — compile error, runtime panic, timeout, or an explicit error returned from Evaluate — blocks the login.
When script_fail_open is true, errors are logged and the unmodified claims are passed through. Use this for non-critical enrichment where availability matters more than correctness.
Terraform¶
resource "justiam_application" "my_app" {
name = "my-app"
type = "oidc"
# ... other fields ...
claim_script = file("${path.module}/scripts/claim_enrich.go")
script_timeout_secs = 5
script_fail_open = false
}
For SAML:
resource "justiam_saml_config" "my_saml" {
app_id = justiam_application.my_saml_app.id
# ... other fields ...
attribute_script = file("${path.module}/scripts/attr_enrich.go")
script_timeout_secs = 5
script_fail_open = false
}
OIDC vs SAML differences¶
| OIDC | SAML | |
|---|---|---|
| Script field | claim_script |
attribute_script |
request.TokenIP() |
Available (IP at /token time) |
Always empty |
| Applied to | ID token + userinfo claims | Assertion attributes |
| Hook runs when | /authorize (patch stored in auth code, applied at /token//userinfo) |
All 3 assertion paths (SP-initiated, IdP-initiated, metadata) |
More examples¶
See Script Examples for additional post-render scripts:
- Inject user attributes — copy custom profile fields into token claims
- Group-based roles — map group memberships to a
rolesarray claim - Deny outside business hours — restrict app access by time of day
- Enrich from external API — fetch metadata from an HR system at login
- IP allowlist — deny login from untrusted networks