Finlay.worksBareBones Ticketing manual

Customer Portal SSO Handoff Contract

For implementers. This page is written for a developer building the Customer Area side of the Customer Portal single sign-on (SSO) handoff. It is not written for office staff, and it assumes you write code.

The Customer Area sends one signed form POST. BareBones Ticketing validates the exact bytes and either accepts the assertion or refuses it. This page is the wire contract. Administrator configuration — which services customers see, the 8-hour session, secret rotation — is in Customer Portal.

Anything not listed here is non-conforming: extra fields, missing fields, a different field order, a different encoding, a query-string payload, an audience taken from the request host, or a signing key derived by decoding the shared secret.

Before you start

Get these from the administrator who configured the integration, through a protected channel:

  • the Audience value BareBones Ticketing displays;
  • the Expected issuer value BareBones Ticketing displays;
  • the Customer Area return address;
  • the shared secret, displayed once at generation; and
  • the installation's HTTPS base address.

You also need a host clock synchronised to Coordinated Universal Time (UTC), a cryptographically secure random number generator, a stable opaque customer identifier, and a provider-verified email address.

Keep the secret, the payload, the signature, the nonce, the subject, and the email out of source control, logs, exception messages, metric labels, traces, build output, and test fixtures.

The request

POST /signin-customer-portal-sso
Content-Type: application/x-www-form-urlencoded

The callback path is fixed. Build the origin from the installation's configured public base address. Never take it from customer input.

The receiver refuses the request before reading the body if any of these is true:

Rule Enforced as
No query string Any query string is refused
Content type First token must be application/x-www-form-urlencoded
Body size Content-Length above 8,192 bytes is refused; the endpoint also carries a request size limit of 8,192 bytes
No file parts Any uploaded file is refused
Field count The form must contain exactly two fields
Field names Exactly one sso and exactly one sig, one value each
sso length At most 6,144 characters
sig length Exactly 64 characters

Use HTTPS. Do not put sso or sig in the URL.

The two body-size rules are enforced at different layers, and they fail differently. The Content-Length check is part of the endpoint's own refusal path: an over-declared length is refused as a plain 401 like every other contract failure. The 8,192-byte request size limit is a transport-layer backstop for a request that does not declare an honest length — a chunked body, or one whose Content-Length understates the bytes. Exceeding that limit fails the read at the framework layer with a size error (HTTP 413), not the uniform 401. This is the one refusal you can tell apart from the others, and it is deliberate: it only ever signals "too large", never anything about an identity, grant, or customer. A conforming sender never approaches 8,192 bytes, so you should not see it.

Inner payload fields

Build one query-style Unicode Transformation Format 8-bit (UTF-8) string with exactly these nine fields, in exactly this order. The receiver compares the decoded field name at each position against a fixed list, so position and name must both match.

Position Field Value
1 v The literal 1
2 iss The Expected issuer value, byte for byte
3 aud The Audience value, byte for byte
4 iat Issue time, UTC Unix seconds, digits only
5 exp Expiry time, UTC Unix seconds, digits only
6 nonce 32 fresh random bytes, unpadded Base64url
7 sub Stable opaque customer identifier, 1 to 256 characters, no control characters
8 email Provider-verified email, 3 to 320 characters, no control characters
9 portal_access The lowercase literal true or false

Duplicate fields, unknown fields, missing fields, and a different order are all refused. The receiver splits on & and requires exactly nine parts, each containing exactly one =.

Issuer

iss is the normalised HTTPS origin of the configured Customer Area return address: scheme, host, and a non-default port if there is one. No path, no query, no fragment, no trailing slash. Compare byte for byte — the receiver uses an ordinal string comparison.

Do not derive it from the current page, a proxy header, or a customer-supplied address.

Audience

aud is the installation's configured public base address with the fixed callback path appended, and any trailing slash removed. The receiver builds it only from BareBonesTicketing:PublicBaseUrl. It never uses the request host, the Host header, a forwarded header, or the Customer Area address.

Time

iat and exp are UTC Unix seconds, parsed as unsigned decimal digits. No sign, no whitespace, no separators.

The receiver compares against its own clock at the moment the request arrives and refuses the assertion unless all four hold:

  • exp is strictly later than iat;
  • exp minus iat is at most 5 minutes;
  • iat is at most 60 seconds ahead of receiver time; and
  • exp is no more than 60 seconds in the past at receiver time.

The last rule is a tolerance for clock skew, not extra lifetime. Set exp in the future. An assertion whose exp has already passed by more than 60 seconds is refused.

The 5 minutes is a ceiling, not a retry budget. One browser handoff needs far less.

Nonce

Generate exactly 32 fresh cryptographically random bytes for every assertion, including every portal_access=false assertion. Encode them as unpadded Base64url: - for +, _ for /, trailing = removed. The receiver decodes the value, requires exactly 32 bytes, and re-encodes it — if the re-encoded text differs from what you sent, the assertion is refused. Padded or standard Base64 therefore fails.

Never reuse a nonce. See Replay for what the receiver stores.

Subject

sub is your opaque stable customer identifier. Keep it stable across email, name, and subscription changes. Do not use an email address.

The receiver does not store the raw subject. It stores the SHA-256 hash of the issuer and subject, length-prefixed, as the identity key. Changing sub creates a different identity. Sending another customer's sub binds to that customer's ticket history. There is no subject migration in this protocol.

Email

Send the current provider-verified contact address. The receiver validates it as a parseable mail address that round-trips unchanged, then stores it as read-only contact data on the requester.

Email never selects the identity and never transfers ticket ownership. After a later valid handoff the receiver updates the stored address if it differs.

Entitlement

portal_access carries one boolean, and nothing else.

  • true — this customer may receive or refresh a session.
  • false — this customer must not receive a session, and any current grant is revoked.

Do not send plan, price, payment, subscription, licence, renewal, role, group, department, project, or workflow facts. The receiver does not read them, and any extra field makes the payload non-conforming.

Encoding the payload

For each of the nine name/value pairs:

  1. Encode the text as UTF-8.
  2. Percent-encode both the name and the value with RFC 3986 behaviour — the receiver checks each part against Uri.EscapeDataString, so your encoder must match it exactly.
  3. Join the encoded name to the encoded value with one =.
  4. Join the nine pairs with &.

Do not use + for a space. Do not normalise, reorder, or re-encode after the string is built. The receiver percent-decodes each part, re-encodes it, and refuses the assertion if the result differs from what arrived. It also refuses any raw byte above 0x7F in the encoded text.

The shape, with placeholders:

v=1&iss={issuer}&aud={audience}&iat={seconds}&exp={seconds}&nonce={nonce}&sub={subject}&email={email}&portal_access={true|false}

Building sso

  1. Take the exact UTF-8 bytes of the payload string.
  2. Stop if that is more than 4,096 bytes. The receiver refuses it.
  3. Encode the bytes as standard Base64, with padding.
  4. Do not use Base64url. Do not alter the result.

The receiver decodes sso, re-encodes the bytes as standard Base64, and requires the result to equal the sso text it received.

Building sig

The key is the UTF-8 bytes of the secret string exactly as displayed. The secret happens to look like Base64url text — it is 32 random bytes encoded that way — but do not decode it. Decoding it produces a different key and every signature fails.

The message is the UTF-8 bytes of the sso text, not the payload and not the raw bytes.

sig = lowercase_hex( HMAC_SHA256( key   = UTF8(shared_secret_as_displayed),
                                 message = UTF8(sso_text) ) )

The result is exactly 64 characters from 0-9 and a-f. Uppercase hexadecimal is refused. The receiver compares in constant time.

The receiver checks the signature before it decodes the payload. A malformed payload with a wrong signature is reported as a signature failure, not a payload failure — not that you can see either (see Refusals).

Building the form

Form-encode exactly two fields:

sso={percent-encoded sso}&sig={hex signature}

The outer form encoding must percent-encode the +, /, and = characters that standard Base64 produces. If your form encoder writes + unescaped, the receiver decodes it as a space and the signature fails. Verify your encoder against this before you test anything else.

Replay

The receiver stores the SHA-256 hash of the 32 nonce bytes under a unique constraint, in the same database transaction as the identity, grant, and audit changes for that handoff. Two requests carrying the same nonce cannot both succeed, including concurrently — the loser is classified as a replay and refused.

Stored nonce hashes are kept until the assertion's exp plus 60 seconds, then pruned. This is not a shorter replay window: an assertion whose nonce record has been pruned is already outside its freshness bounds and is refused on time.

Never retry an assertion. To try again, build a completely new payload with a new iat, a new exp, and a new nonce, and sign it with the current secret.

Ordering and superseded assertions

The receiver keeps a watermark per configured provider and derived subject, holding the last accepted iat and its portal_access value. Each accepted assertion is compared against it under a row lock:

Incoming Result
iat older than the watermark Refused as superseded; nothing changes
iat equal, watermark false, incoming true Refused as superseded; nothing changes
iat equal, watermark true, incoming false Accepted; watermark moves to false
iat equal, same value as the watermark Accepted; watermark unchanged
iat newer Accepted; watermark moves to the incoming value

A denial wins at equal iat, in either arrival order. A genuinely newer true restores access after an earlier false.

Use accurate, forward-moving issue times per customer. If eligibility drops to false, send a current false assertion when the customer next follows the Customer Area path. There is no webhook and no background revocation channel.

What the receiver does

Accepted, portal_access=true. The receiver maps or creates one requester-only identity, creates or refreshes a server-side grant expiring 8 hours after the moment the request arrived, issues a session cookie with the same expiry, and redirects the browser to /portal/tickets/new, the page titled Service Catalog. The grant does not slide and the assertion's exp does not affect it.

Accepted, portal_access=false. The receiver records the decision and the nonce, revokes a current grant for that identity if one exists, and redirects to the configured Customer Area return address. It creates no identity for an unknown subject and issues no session. If the browser is currently signed in as that same customer, that session is signed out.

Known subject whose user account is disabled. Refused. A disabled account is not reactivated by an eligible handoff.

Refused, any reason. HTTP 401, no body, no reason. The browser is not redirected anywhere.

Refusals

The receiver never tells the sender why an assertion failed. It records an internal reason code as a log warning on the receiver, and nothing else. Rejected assertions write no audit event and change no state — no identity, no grant, no nonce row, no watermark.

The warnings are rate-limited to one entry per reason code per minute. A burst of failures with the same cause produces one line, not one line per attempt, so the log does not show how many attempts were made.

These are the internal codes, so you can talk to an administrator reading the receiver's logs. You will not see them in a response.

Code Cause
IntegrationUnavailable Not configured, not enabled, no return address, or no secret
SecretUnavailable The stored secret could not be unprotected
AudienceUnavailable The installation's public base address is missing or not valid HTTPS
EnvelopeInvalid sso or sig missing, sso over 6,144 characters, or sig not 64 characters
SignatureInvalid Non-hexadecimal characters in sig, or the signature does not match
PayloadInvalid Not canonical Base64, over 4,096 decoded bytes, not valid UTF-8, wrong field count, wrong field name or position, or non-canonical percent-encoding
VersionInvalid v is not 1
IssuerInvalid iss does not equal the expected issuer
AudienceInvalid aud does not equal the expected audience
TimestampInvalid iat or exp unparseable, or outside the lifetime and skew bounds
NonceInvalid Not 32 bytes, or not canonical unpadded Base64url
SubjectInvalid sub empty, over 256 characters, or containing a control character
EmailInvalid email outside 3 to 320 characters, containing a control character, or not a parseable address
EntitlementInvalid portal_access is not exactly true or false
EntitlementSuperseded A newer or equal-time decision already stands
ReplayDetected The nonce has been used
InactiveIdentity The matching account is disabled
OperationalFailure The receiver could not write the transaction

Do not loop on a refusal, and do not infer from one whether an identity, a service, a grant, or a customer record exists.

Secret rotation

There is one current secret and no overlap window.

  • A handoff arriving after rotation completes must be signed with the replacement.
  • A handoff that already holds the receiver's configuration lock may commit against the former secret.
  • A former-secret handoff cannot commit once rotation has completed.
  • Rotation does not revoke existing grants.
  • Disabling the integration refuses existing grants on their next request.

Do not send two signatures, a fallback secret, or an automatic retry with the old secret. Coordinate the cutover with the administrator; the procedure is in Customer Portal.

Sender conformance checklist

If it does not work

  • Every handoff is refused and nothing else changed. Check the secret first. A Base64url-decoded key is the most common cause and produces a plain 401 like every other failure.
  • It works locally and fails through a proxy. Check that the form encoder escapes + in sso, and that aud still matches the displayed value.
  • The first handoff succeeds and the second is refused. Check that you generate a new nonce per assertion, not per session or per customer.
  • A newer eligible handoff is refused. An equal-time or later denial already stands. Issue a new assertion with a genuinely later iat.
  • Handoffs stop working after an administrator action. Ask whether the secret was rotated or the integration disabled.

Test with synthetic identities. Record only case names, timestamps, and pass or fail — never actual protocol values.