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:
expis strictly later thaniat;expminusiatis at most 5 minutes;iatis at most 60 seconds ahead of receiver time; andexpis 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.
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:
- Encode the text as UTF-8.
- 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. - Join the encoded name to the encoded value with one
=. - 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
- Take the exact UTF-8 bytes of the payload string.
- Stop if that is more than 4,096 bytes. The receiver refuses it.
- Encode the bytes as standard Base64, with padding.
- 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
+insso, and thataudstill 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.
Related pages
- Customer Portal — configuring and enabling the integration
- Audit Events
- Limits and scope
- Glossary