Authenticator

From Obsidian Scheduler
Jump to navigationJump to search

Obsidian ships with four built-in authenticators: native (database), LDAP, OAuth (OIDC), and SAML 2.0. A custom authenticator can also be implemented by extending the authenticator interface. The active authenticator is selected by the com.carfey.suite.security.Authenticator property in the Obsidian configuration file (com.carfey.properties or YAML equivalent).

OAuthAuthenticator (OIDC SSO)

OAuthAuthenticator enables Single Sign-On via any OIDC-compliant identity provider. It implements OAuth 2.0 Authorization Code + PKCE (S256). Full configuration reference: Advanced Configuration — OAuth / OIDC Authentication Properties.

How browser login works

When OAuthAuthenticator is active, the Obsidian login page shows a single "Sign in with <displayName>" button instead of a username/password form. Clicking the button redirects the user to the identity provider's login page. After successful authentication at the IdP, the browser is redirected back to Obsidian with an authorization code. Obsidian exchanges the code for tokens at the IdP token endpoint and validates the id_token signature, issuer, audience, expiry, and nonce.

No Obsidian database user record is created or required. Identity and roles come entirely from the validated token.

User identity and display

Obsidian uses the sub claim of the id_token as the stable internal user ID (stored in audit logs and session state). The sub claim is IdP-guaranteed unique and is never reassigned. A separate claim.displayName property (default: preferred_username) controls what name appears in the Obsidian UI nav bar. Changing claim.displayName does not affect audit logging.

Roles from IdP groups

After the code exchange, Obsidian reads a configured groups claim from the id_token (or access token, or userinfo endpoint). Each group value is mapped to an Obsidian role via indexed role.<idx>.group / role.<idx>.roleName properties. At least one mapping to the Admin role is required. A user whose groups do not match any mapping authenticates successfully but has no access to role-gated actions (same behavior as a DB/LDAP user with no assigned roles).

Valid Obsidian roles: Admin, API, Write, LimitedRead, Operator, Author, and folder-based roles following the <folder>-(Write|Author|Operator) convention (e.g. Maintenance-Author).

REST API Bearer authentication

REST clients can authenticate to Obsidian's REST API using a signed JWT access token in the Authorization: Bearer <token> header. Obsidian validates the token locally using cached JWKS keys. The userinfo endpoint is not called on the Bearer path. The client must have the API Obsidian role.

Logout modes

Two logout modes are supported:

  • local (default) — Obsidian invalidates its own session. The IdP session remains active (useful for SSO environments where other applications share the IdP session).
  • rp — RP-initiated logout. Obsidian redirects the browser to the IdP's end_session_endpoint, which terminates the IdP session. Requires the IdP to expose end_session_endpoint in its discovery document.

v1 limitations

  • Opaque (non-JWT) access tokens are not supported on the REST/Bearer path.
  • No jti replay protection (mitigate with short token lifetimes at the IdP).
  • Microsoft Entra multi-tenant apps are not supported.
  • No post-login IdP session validity check (mitigate with a short session timeout in web.xml).
  • Dynamic role map (dynamicRole.prefix/suffix) requires a server restart when new root job folders are added.
  • Installer UI supports up to 10 static group→role pairs; the runtime supports unlimited.

SAMLAuthenticator (SAML 2.0 SSO)

SAMLAuthenticator enables Single Sign-On via any SAML 2.0 identity provider, using Service Provider (SP) initiated browser flows. The class to configure is com.carfey.suite.security.saml.SAMLAuthenticator. Full configuration reference: Advanced Configuration — SAML Authentication Properties.

How browser login works

When SAMLAuthenticator is active, the Obsidian login page shows a single "Sign in with <displayName>" button instead of a username/password form. If displayName is left blank the button reads "Sign in with SSO". Clicking it has Obsidian build a SAML AuthnRequest — signed with the SP keystore when signRequests is enabled — and redirect the browser to the identity provider using the HTTP-Redirect binding. After successful authentication at the IdP, the IdP posts a SAML response back to Obsidian's Assertion Consumer Service (ACS) endpoint.

Obsidian validates the response status, the assertion signature against the IdP signing certificate published in the IdP's federation metadata, the issuer, the Destination and Recipient values, the AudienceRestriction against your SP entity ID, and the NotBefore / NotOnOrAfter time window (widened by clockSkewSeconds, default 60 seconds). On success Obsidian replaces the browser session with a new one and returns the user to the page originally requested, or to /.

No Obsidian database user record is created or required. Identity and roles come entirely from the validated assertion.

A failed callback invalidates the session and returns the browser to the login page. The underlying cause is written to the Obsidian log with a short correlation id that is not shown to the user.

Service Provider endpoints

All SAML endpoints live under /saml in the Obsidian web application:

  • /saml/login — starts SP-initiated login and issues the AuthnRequest.
  • /saml/callback — the Assertion Consumer Service, receiving the IdP's SAMLResponse by HTTP-POST. Your assertionConsumerServiceUrl must point here.
  • /saml/logout — starts logout from the Obsidian UI.
  • /saml/slo — single logout endpoint, handling an IdP-initiated LogoutRequest and the IdP's LogoutResponse.
  • /saml/metadata — serves Obsidian's SP metadata document for registration at the IdP.

Identity provider metadata

Obsidian reads IdP federation metadata from either idpMetadataUrl or idpMetadataFile, and caches it for metadata.ttlHours (default 24). Signing certificates come from that metadata, so key rotation at the IdP is picked up on the next refresh. If a refresh fails, Obsidian logs a warning and continues with the cached copy.

User identity and display

By default Obsidian uses the assertion's NameID as the stable internal user ID stored in audit logs and session state. Binding a different attribute with attribute.user is supported but logs a warning at startup, because a value the IdP may reassign risks one user inheriting another's history. A transient nameIdFormat also logs a warning: transient NameIDs are regenerated for each session, so every login appears as a new user. attribute.displayName optionally supplies the name shown in the Obsidian UI nav bar.

Roles from SAML attributes

Obsidian reads group memberships from the assertion attribute named by attribute.groups, or matched by attribute.groups.friendlyName. attribute.groups.valuePath and attribute.groups.stripPrefix help with structured or prefixed values such as directory distinguished names. Each group value is mapped to an Obsidian role via indexed role.<idx>.group / role.<idx>.roleName properties, exactly as for OAuth, and at least one mapping to the Admin role is required.

By default (requireRole=true), a user whose groups match no role mapping cannot complete login. Setting requireRole=false lets such a user authenticate but with no access to role-gated actions, the same behavior as a DB or LDAP user with no assigned roles.

dynamicRole.prefix and dynamicRole.suffix derive folder role mappings from your root job folders at startup, so adding a new root folder requires a restart before its roles resolve. Either attribute.groups or dynamicRole.prefix must be configured — with neither, every SAML user would resolve to zero roles, so the authenticator fails validation at startup.

Valid Obsidian roles: Admin, API, Write, LimitedRead, Operator, Author, and folder-based roles following the <folder>-(Write|Author|Operator) convention (e.g. Maintenance-Author).

Request signing and assertion trust

signRequests (default enabled) signs the outbound AuthnRequest and SP LogoutRequest, and requires keystore.path, keystore.password and keystore.alias. wantAssertionsSigned (default enabled) requires the IdP to sign assertions. Disabling both is accepted but logs a warning at startup, because assertions are then neither signed nor verified — do not do this in production.

Encrypted assertions are not supported. The IdP must send signed, unencrypted assertions. Publish signing-capable key material in your SP metadata so that IdPs such as AD FS do not choose to encrypt.

Logout modes

Two logout modes are supported:

  • local (default) — Obsidian invalidates its own session. The IdP session remains active.
  • slo — SP-initiated single logout. Obsidian sends a signed LogoutRequest to the IdP's HTTP-Redirect SingleLogoutService. If the IdP metadata advertises no HTTP-Redirect SLO endpoint, Obsidian logs a warning and falls back to local logout. postLogoutRedirectUri sets the landing page after the IdP's LogoutResponse; when blank the browser returns to /.

An IdP-initiated LogoutRequest posted to /saml/slo is honoured in either mode.

No REST API access

Obsidian's REST API is unavailable while SAMLAuthenticator is active. There is no SAML equivalent of the OAuth JWT Bearer path, and REST credential authentication is refused outright. Deployments that need REST access must run a separate Obsidian instance with an authenticator that supports it.

v1 limitations

  • The REST API is not available while SAMLAuthenticator is active.
  • SP-initiated login only. The callback requires an existing browser session, and a response carrying InResponseTo must match a pending AuthnRequest from that session, which is single-use and expires after five minutes.
  • Encrypted assertions are not supported.
  • Replay protection relies on that single-use AuthnRequest match plus the assertion time window; there is no persistent assertion ID store, so keep assertion lifetimes short at the IdP.
  • Dynamic role map (dynamicRole.prefix/suffix) requires a server restart when new root job folders are added.
  • Installer UI supports up to 10 static group→role pairs; the runtime supports unlimited.

Developing an Authenticator

Obsidian uses any valid implementation of the com.carfey.suite.security.Authenticator or com.carfey.suite.security.remember.Authenticator interface.

Implementation of a Custom Authenticator

com.carfey.suite.security.User authenticate(String username, String pass) throws com.carfey.suite.security.Authenticator.AuthenticationFailedException

As of Obsidian 6.1.1, you may optionally implement REST authentication distinctly. Defaults to standard authentication when not implemented. com.carfey.suite.security.User authenticateREST(String username, String pass) throws com.carfey.suite.security.Authenticator.AuthenticationFailedException

This method authenticates and returns a user with roles defined. Given a user name and a password either return a valid com.carfey.suite.security.User object or throw a com.carfey.suite.security.Authenticator.AuthenticationFailedException.


If authentication is successful, the com.carfey.suite.security.User returned must have all its role memberships defined. This is done using the com.carfey.suite.security.Role class. The assignment of Roles to a User can be done using any of the public constructors/setters or logical combination thereof defined below.

public boolean supportsRememberMe()

From com.carfey.suite.security.remember.Authenticator, available from Obsidian 3.5.0 and removed in Obsidian 7.0.0.

This method indicated whether the Authenticator supported Obsidian's remember me feature in the Web Admin login. The Remember Me feature and the com.carfey.suite.security.remember.Authenticator interface were removed in Obsidian 7.0.0, so this method is no longer part of the authenticator contract.

Users

public User(String userId)

public User(String userId, Set<Role> roles, String firstName, String lastName, String email, boolean active*)

public User(String userId, List<Role> roles, String firstName, String lastName, String email, boolean active*)

public void setRoles(Set<Role> roles)

public void setRoles(List<Role> roles)

* Note: If you wish to implement active user enabling/disabling, you must do so in your Authenticator throwing com.carfey.suite.security.Authenticator.AuthenticationFailedException when inactive users attempt to login.

Roles

There are convenience constants that you should use in defining your role memberships. They can be found at com.carfey.ops.Constant. The constants are ADMIN_ROLE, WRITE_ROLE, LIMITED_READ_ROLE and API_ROLE. As of 5.0.0, you can also use OPERATOR_ROLE and AUTHOR_ROLE. Default rights are assumed for any authenticated user. Therefore, if someone authenticates that should not have access, throw a com.carfey.suite.security.Authenticator.AuthenticationFailedException.

When assigning the user's com.carfey.suite.security.Roles, use the constructor public Role(String roleId, String roleName) using the appropriate constant for both the roleId and roleName. Role meanings are defined in User Rights.

Putting it All Together

Finance attempts to log in to Obsidian, gives valid credentials but should not be accessing Obsidian.

import static com.carfey.ops.Constant.*;

import com.carfey.suite.security.Authenticator.AuthenticationFailedException;
import com.carfey.suite.security.Role;
import com.carfey.suite.security.User;


public User authenticate(String username, String pass) throws AuthenticationFailedException {
    //usernameis "financeGuy"
    //pass is "mystrongpass"
    //credentials are valid, but user does not have any rights to Obsidian

    throw new AuthenticationFailedException(String.format("User [%s] is not authorized to use Obsidian Scheduler.", username));
}

Fred logs in using his username fredScheduler and his password badpass. You determine that his password is invalid.

import static com.carfey.ops.Constant.*;

import com.carfey.suite.security.Authenticator.AuthenticationFailedException;
import com.carfey.suite.security.Role;
import com.carfey.suite.security.User;


public User authenticate(String username, String pass) throws AuthenticationFailedException {
    //usernameis "fredScheduler"
    //pass is "badpass"
    //credentials are invalid

    throw new AuthenticationFailedException(String.format("User [%s] could not be authenticated.", username));
}

Fred logs in using his username fredScheduler and his password mystrongpassword. You determine that his password is valid and matches with the user and he has WRITE_ROLE rights.


public User authenticate(String username, String pass) throws AuthenticationFailedException {
    //usernameis "fredScheduler"
    //pass is "mystrongpassword"
    //credentials are valid, he has WRITE_ROLE

    User user = new User(username);
    user.setRoles(Arrays.asList(new Role(WRITE_ROLE, WRITE_ROLE)));
    return user;
}

Tina logs in using her username tinaOperator and her password mystrongpassword. You determine that her password is valid and matches with the user and she has Default rights.


public User authenticate(String username, String pass) throws AuthenticationFailedException {
    //usernameis "tinaOperator"
    //pass is "mystrongpassword"
    //credentials are valid, Tina has default access

    return new User(username);
}

The intern logs in using his username newGuy and his password mystrongpassword. You determine that his password is valid and matches with the user and he has LIMITED_READ_ROLE rights.


public User authenticate(String username, String pass) throws AuthenticationFailedException {
    //usernameis "newGuy"
    //pass is "mystrongpassword"
    //credentials are valid, the intern has LIMITED_READ_ROLE access

    User user = new User(username);
    user.setRoles(Arrays.asList(new Role(LIMITED_READ_ROLE, LIMITED_READ_ROLE)));
    return user;
}

The new hire logs in using her username newHire and her password is i am a new hire. You determine that her password is valid and matches with the user and she has OPERATOR_ROLE rights only for the job folder roots DevOps and FinOps.

private static final String DEV_OPS_OPERATOR = "DevOps-" + OPERATOR_ROLE;
private static final String FIN_OPS_OPERATOR = "FinOps-" + OPERATOR_ROLE;
public User authenticate(String username, String pass) throws AuthenticationFailedException {
    //usernameis "newHire"
    //pass is "i am a new hire"
    //credentials are valid, the new hire has OPERATOR_ROLE access for job folder roots DevOps and FinOps

   User user = new User(username);
   user.setRoles(Arrays.asList(new Role(DEV_OPS_OPERATOR, DEV_OPS_OPERATOR), new Role(FIN_OPS_OPERATOR, FIN_OPS_OPERATOR)));
   return user;
}

Customizing our LDAP Authenticator

com.carfey.suite.security.LdapAuthenticator is Obsidian's LDAP Authentication class that, when combined with its configurability that is documented here, meets most needs. But with the large variety of LDAP servers and potential implementations of dn strings, role and group definition and membership and even authentication methods supported, you may need to tweak its use somewhat. Rather than require you to write your own LDAP Authenticator, we have made efforts to make ours flexible enough to be specialized.

Specialization points

protected void buildUpContextEnvironment(String pass, String dn, Hashtable<String, String> environment)

Stores needed environment attributes for authentication using javax.naming.directory.InitialDirContext.

Currently stores:

Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"
Context.PROVIDER_URL, {{ldap_url}} 
Context.SECURITY_AUTHENTICATION, "simple" //or overridden securityAuthentication by configuration com.carfey.suite.security.LdapAuthenticator.securityAuthentication 
Context.SECURITY_PRINCIPAL, {{user_dn}} 
Context.SECURITY_CREDENTIALS, {{user_pass}}

protected boolean isMemberOfGroup(DirContext authContext, String groupName, String dn) throws NamingException

Checks the authenticated DirContext for group membership. Used to determine Default rights to Obsidian, in addition to its defined rights roles. Currently queries "uniquemember","uniqueMember","member","roleOccupant", "memberOf" and "MemberOf" attributes for any match on the dn.

Deploying your Authenticator

Once your authenticator class is written, you need to deploy the compiled class and any dependent libraries to your admin web application instances as JAR files. These should be placed under the /WEB-INF/lib directory of either the Obsidian web application directory or WAR file.