How it works
The login
Bullhorn’s login has four steps, as its Getting Started with REST page describes them:
- loginInfo:
GET rest-services/loginInfo?username=...gives the OAuth URL and the REST URL of the API user’s data center. The answer is kept in the store for a day. Setoauth_urlandrest_urlto skip this call. - Authorize:
GET oauth/authorizewith the client id, the username and the password answers with a redirect. The authorization code is in itsLocation. The transport does not follow the redirect. - Token:
POST oauth/tokenwith the code, the client id and the client secret, in the form body, gives an access token and a refresh token. - REST login:
POST rest-services/loginwith the access token gives theBhRestTokenand the REST URL of your corporation. Every call after that goes to that REST URL with the token in theBhRestTokenheader.
The client does this on the first call that needs it. Creating the client calls nothing, so an application without credentials boots and fails only where it calls Bullhorn.
One session for everybody
The session (REST URL, session token and refresh token) goes into the token store under a key made from a hash of the client id and the username. Every process that shares the store shares the session. Bullhorn writes that it applies strict limits to login rates and that you must not log in before every request; a shared store is how this package keeps to that.
| Store | Shared between | For |
|---|---|---|
Laravel\CacheTokenStore | everything that reaches the cache | Laravel applications (the default) |
Token\FileTokenStore | every process on the server | PHP projects without Laravel |
Token\MemoryTokenStore | one process | scripts, workers, tests |
Renewal
Bullhorn ends a session after the ttl without a call, or earlier. The next call then gets a 401. The client:
- takes the lock of the API user,
- reads the store again: when another process has put a different session there, it takes that one,
- else renews with the refresh token (
grant_type=refresh_token) and a new REST login, - and when Bullhorn no longer accepts the refresh token, logs in again,
- then repeats the call, once.
The lock matters because Bullhorn replaces the refresh token every time a new access token is made. Two processes that renew at the same moment would each use the same refresh token, and the second one would fail. With the lock, the second process finds the first one’s session in the store and uses it.
A 401 on a session that was made a moment ago is not repeated: that is not about the session, and repeating it would only log in again. A 429 or a 5xx is never repeated either.
Secrets
- The client secret goes only in the form body of the token request.
- The session token goes only in the
BhRestTokenheader. - Bullhorn documents the username and the password as query parameters of the authorize step, and the access token as a query parameter of the REST login. Those two URLs carry them; the client never quotes a URL in a message.
- Every exception message has the client secret, the password and every token the client has seen replaced by
[redacted], also in their URL, JSON and HTML encodings. #[\SensitiveParameter]keeps the values out of stack traces.- The Laravel cache holds the session encrypted with
APP_KEY. A file store holds it in a0600file.
Errors
The client throws two exceptions:
AuthenticationExceptionwhen the login fails with a 4xx, or the config is incomplete. Someone has to fix the credentials; retrying does not help.BullhornExceptionfor every other failure,AuthenticationExceptionincluded as a subclass.status()is the HTTP status, or0when there was no response. A 5xx or 429 during the login is aBullhornExceptionand not anAuthenticationException: that is an outage.
The message holds the whole response body. Bullhorn names the rejected field at the end of a long body, and a shortened message hides it. The body is not kept as a property, because it can hold candidate data and error trackers serialise exception properties. What is kept holds no data: errorKey() and invalidFields().