Tutorials & copy-pasteable recipes.

Short walkthroughs for the most common things you'll do on day one. Every recipe is a curl command — no SDK required — so you can verify the platform behaves before writing integration code. Set $HOST, $TENANT and $TOKEN once and every block below just works.

export HOST=https://sso.example.com
export TENANT=acme
export TOKEN=wf_live_...

Workforce SSO

1. Register a tenant

curl -X POST $HOST/api/admin/tenants \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"slug":"acme","name":"Acme Corporation"}'

2. Wire up an LDAP upstream

curl -X POST $HOST/api/admin/tenants/1/ldap-providers \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "corp-ad",
      "providerType": "ACTIVE_DIRECTORY",
      "url": "ldap://dc01.acme.internal",
      "bindDn": "CN=svc-weldforge,OU=Service Accounts,DC=acme,DC=internal",
      "bindPassword": "REDACTED",
      "userBaseDn": "OU=Users,DC=acme,DC=internal",
      "userSearchFilter": "(|(userPrincipalName={0})(sAMAccountName={0}))",
      "enabled": true
    }'

3. Register a SAML Service Provider

curl -X POST $HOST/api/admin/saml/service-providers \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "entityId": "https://wiki.acme.internal/saml/metadata",
      "name": "Internal Wiki",
      "acsUrl": "https://wiki.acme.internal/saml/acs",
      "nameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
      "encryptAssertions": true,
      "spCertificate": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----"
    }'

4. Grab the IdP metadata for the other side

curl $HOST/t/$TENANT/saml2/idp/metadata -o weldforge-idp-metadata.xml

Customer Identity

1. Discover the OIDC configuration

curl $HOST/t/$TENANT/.well-known/openid-configuration | jq

2. Register a user from your front-end

curl -X POST $HOST/api/auth/register \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "email":    "alice@example.com",
      "name":     "alice",
      "password": "CorrectHorse-Battery-Staple-9"
    }'

3. Log in — password only

curl -X POST $HOST/api/auth/login \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "identifier": "alice@example.com",
      "password":   "CorrectHorse-Battery-Staple-9"
    }'

4. Refresh an access token

curl -X POST $HOST/api/auth/refresh \
    --cookie "refresh_token=..."

Integrating an app with OIDC

The pattern below is what Write-Buddy uses in production: one tenant owns a SPA, a mobile app, and a backend service. The SPA and mobile use Authorization Code + PKCE; the backend uses client_credentials for scheduled jobs. All three validate the same WeldForge-issued JWT.

Every /api/admin/* recipe in this section assumes $TOKEN is a service-account token (wf_svc_…) for a TENANT_ADMIN of the write-buddy tenant — see Create a service account with TENANT_ADMIN below for how to mint one. The token's owning tenant scopes every call automatically; the slug never needs to be repeated in the URL.

1. Bootstrap the tenant

One-shot SUPER_ADMIN call against the platform's default tenant:

curl -X POST $HOST/api/admin/tenants \
    -H "x-app-authorization: $SUPER_ADMIN_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"slug":"write-buddy","name":"Write-Buddy"}'

Mint a TENANT_ADMIN service account on the new tenant (recipe under API keys & service accounts) and capture its token as $TOKEN. The remaining recipes use that.

2. Register OIDC clients

WeldForge distinguishes public and confidential clients via the requirePkce flag rather than a separate type. true rejects any Authorization Code exchange that doesn't present a matching code_verifier — that is the entire mechanism that makes a SPA or mobile client safe without a secret. false mints a clientSecret on create (returned exactly once) and is appropriate for backend services using client_credentials.

# SPA — Authorization Code + PKCE, no secret
curl -X POST $HOST/api/admin/oidc/clients \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "clientId":     "wb-admin-web",
      "name":         "Write-Buddy Admin Web",
      "grantTypes":   ["authorization_code","refresh_token"],
      "requirePkce":  true,
      "redirectUris": ["https://app.write-buddy.example/oauth/callback"],
      "scopes":       ["openid","profile","email","offline_access"]
    }'

# Mobile app — same shape, different redirect URI
curl -X POST $HOST/api/admin/oidc/clients \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "clientId":     "wb-mobile",
      "name":         "Write-Buddy Mobile",
      "grantTypes":   ["authorization_code","refresh_token"],
      "requirePkce":  true,
      "redirectUris": ["online.appiary.writebuddy://oauth/callback"],
      "scopes":       ["openid","profile","email","offline_access"]
    }'

# Backend service — confidential, client_credentials
curl -X POST $HOST/api/admin/oidc/clients \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "clientId":     "wb-backend",
      "name":         "Write-Buddy Backend",
      "grantTypes":   ["client_credentials"],
      "requirePkce":  false,
      "redirectUris": ["https://write-buddy.example/oauth/unused"],
      "scopes":       ["wb.content.read","wb.content.write"]
    }'

The confidential client's response carries clientSecret exactly once — capture it into your secret store immediately. redirectUris is required on every client today, so set a placeholder for back-end-only ones.

3. Map group membership into the roles claim

Every WeldForge access and ID token already carries a roles array — there is no separate claim mapper to configure. The array's contents come from two records: a Role per role name you want a downstream service to recognise, and a group-role mapping that ties a SCIM group to that role.

# 3a. Define the roles your app cares about
for role in admin parent child; do
  curl -X POST $HOST/api/admin/roles \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"name\":\"$role\",\"description\":\"Write-Buddy $role\"}"
done

# 3b. SCIM provisions groups from your upstream IdP. Look up the
#     resulting group id once it is there:
curl -G "$HOST/scim/v2/Groups" \
    -H "x-app-authorization: $TOKEN" \
    --data-urlencode 'filter=displayName eq "wb-admins"' | jq

# 3c. Bind the SCIM group to the role
curl -X POST $HOST/api/admin/group-role-mappings \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"scimGroupId": 42, "roleId": 7, "priority": 10}'

Members of the wb-admins SCIM group now receive "roles":["admin"] on every JWT minted for any of the three clients above.

4. Validate the JWT from a Spring Boot service

The downstream service only needs the issuer URI; Spring discovers the JWKS endpoint and refreshes keys automatically. Add a small converter to lift the roles claim into Spring authorities so @PreAuthorize("hasRole('admin')") just works:

# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.weldforge.org/t/write-buddy
// SecurityConfig.java
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(reg -> reg
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o
                .jwt(j -> j.jwtAuthenticationConverter(new WeldForgeJwtAuthenticationConverter())));
        return http.build();
    }
}
// WeldForgeJwtAuthenticationConverter.java
public final class WeldForgeJwtAuthenticationConverter
        implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();

    @Override public AbstractAuthenticationToken convert(Jwt jwt) {
        var auths = new ArrayList<GrantedAuthority>(scopes.convert(jwt));
        Object roles = jwt.getClaim("roles");
        if (roles instanceof Collection<?> c) {
            for (Object r : c) auths.add(new SimpleGrantedAuthority("ROLE_" + r));
        }
        return new JwtAuthenticationToken(jwt, auths, jwt.getSubject());
    }
}

That is the entire integration. Scope-based authorisation works through Spring's default SCOPE_* authorities; role-based authorisation works through the ROLE_* mapping above.

API keys & service accounts

Create a scoped API key

curl -X POST $HOST/api/admin/app-clients \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "clientName": "reporting-pipeline",
      "scopes": [
        { "path": "/api/admin/users/**", "methods": ["GET"] },
        { "path": "/api/admin/audit/**", "methods": ["GET"] }
      ]
    }'

The response carries apiKey — capture it immediately, it is never returned again. Rotate with:

curl -X POST $HOST/api/admin/app-clients/42/rotate \
    -H "x-app-authorization: $TOKEN"

Create a service account with TENANT_ADMIN

curl -X POST $HOST/api/admin/service-accounts \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "terraform-ci",
      "description": "Used by Terraform Cloud to manage SAML SPs",
      "adminRole": "TENANT_ADMIN"
    }'

The response carries token — again, once only. Present it on subsequent calls as x-app-authorization: wf_svc_….

Internal PKI

Bootstrap a root CA

curl -X POST $HOST/api/admin/pki/ca \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"yearsValid": 10}'

Issue a client certificate bound to a user

curl -X POST $HOST/api/admin/pki/certificates \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "subjectDn":     "CN=alice@acme.test",
      "sans":          ["alice@acme.test"],
      "validityDays":  365,
      "userId":        123
    }'

Verify the CRL with OpenSSL

curl $HOST/t/$TENANT/pki/ca.pem  -o ca.pem
curl $HOST/t/$TENANT/pki/crl.pem -o crl.pem
openssl crl -in crl.pem -CAfile ca.pem -noout -verify

Revoke an issued certificate

curl -X POST $HOST/api/admin/pki/certificates/<serial-hex>/revoke \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"reason": "KEY_COMPROMISE"}'

Webhooks

Subscribe to identity events

curl -X POST $HOST/api/admin/webhooks \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "name":         "siem-feed",
      "targetUrl":    "https://siem.acme.internal/ingest/weldforge",
      "eventFilters": ["auth.*", "admin.*", "pki.cert.*"]
    }'

The response carries a one-time secret. Keep it — the receiver will use it to verify HMAC signatures on every incoming delivery.

Verify the HMAC signature on the receiving side (Node.js)

const crypto = require('crypto');

function verify(secret, header, rawBody) {
    const [tPart, sigPart] = header.split(',');
    const t   = tPart.slice(2);              // "t=..."
    const sig = sigPart.slice(3);            // "v1=..."
    const expected = crypto
        .createHmac('sha256', secret)
        .update(t + '.' + rawBody)
        .digest('hex');
    return crypto.timingSafeEqual(
        Buffer.from(sig, 'hex'),
        Buffer.from(expected, 'hex'));
}

CRM provisioning

Wire up a Salesforce connector

curl -X POST $HOST/api/admin/tenants/1/crm-providers \
    -H "x-app-authorization: $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "name":         "sf-prod",
      "providerType": "SALESFORCE",
      "baseUrl":      "https://acme.my.salesforce.com",
      "apiToken":     "00D...!AR...",
      "fieldMappings": [
        { "source": "email", "target": "Email"     },
        { "source": "name",  "target": "FirstName" }
      ],
      "matchKeys":    ["email"],
      "enabled":      true
    }'

On the next successful login, WeldForge will upsert the user into Salesforce and record the returned contact id for dedupe on subsequent logins.

Managing users across tenants (super-admin)

A user with admin_role = SUPER_ADMIN sees a tenant dropdown at the top of the Users, Roles, and Group-Role-Mappings pages in the admin portal. Selecting a tenant scopes every request on that page to the chosen tenant — no logout, no JWT rotation, no URL gymnastics.

How it works

The dropdown writes the chosen slug to localStorage and an HTTP interceptor stamps it onto every outbound /api/* request as the X-Tenant-Slug header. The backend's JWT filter reads the header and, only when the JWT itself carries the sa: true claim, overrides the request's tenant context to the selected tenant. Non-super-admins keep their JWT's home tenant regardless of any header — the override is gated server-side, not just hidden in the UI.

What the picker affects

  • Users — list, invite, reset MFA, change admin role.
  • Roles — create, list, delete tenant-scoped roles.
  • Group-Role-Mappings — wire SCIM groups to roles for the selected tenant.

Other admin pages (Tenants, App-Clients, Audit, Security) deliberately don't render the picker. They're either inherently cross-tenant, or they should always read against the operator's home tenant for audit clarity.

Audit trail

Every cross-tenant override is logged at INFO level on the API as super_admin_tenant_override actor=… home=… acting=… so you can reconstruct who acted on which tenant's data without parsing JWTs out of HTTP logs. The MDC enrichment filter also stamps the acting tenant onto every downstream log line.

Branding the login & password-reset forms

Each tenant has its own login and password-reset forms rendered from the SPA. To brand them in line with the rest of the customer's site, set the tenant's branding JSON via PUT /api/admin/tenants/{id} — keys logoUrl, headline, tagline, primaryColor, accentColor, bgColor, displayFont, sansFont all flow into CSS variables on the form. The login screen reads these via the public GET /api/auth/tenants/{slug}/branding endpoint before the user has a JWT, so the look-and-feel matches the tenant the user is signing into. Same applies to the /reset-password screen.

More on the way The tutorials here cover the most common getting-started flows. For anything deeper — MFA step-up, federation rules with JSONPath transforms, SCIM bulk operations, or running against a local Docker Compose stack — see the repository's README and the integration tests under src/test/resources/features.