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.
src/test/resources/features.