test(oidc-device-organization): pin the identity swap and _deviceId stability - #91
Merged
Merged
Conversation
…tability The plugin carries the most identity-critical code in the chain — it replaces the approving admin's session with a synthetic one and derives _deviceId, the value the whole bastion vouching chain keys on — and three things about it were pinned nowhere. _deviceId stability across a refresh. It is documented as "stable across refreshes" and PamAccess::_callerId returns it as the bastion identity, so a device whose id changed at refresh would silently lose its vouchers. Now exercised end to end through /pam/heartbeat (pam-access is pulled in as a test-only dependency), including two consecutive heartbeats. The AllowOffline=0 + ownership=organization combination: an access token is issued, no refresh token of either kind, and the identity swap still happens. The swap itself: the token points at the synthetic session and not the admin's, no admin attribute survives the wholesale session copy, userinfo answers as the device, and the device token keeps working after the approving admin's SSO session is removed — which is the entire point of organizational ownership. Plus the negative: an RP without ownership=organization gets no _deviceId. It also pins the constraint the plugin's own design comment leans on: the core /oauth2/token refresh grant answers invalid_grant for these tokens, which is why Open Bastion refreshes through /pam/heartbeat. 52 -> 125 assertions. Closes #71 Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #71. Stacked on #90 → #89 → #88 → #87.
The issue was filed when the plugin had no
t/at all. The #72 fix added one(52 assertions, the fail-closed path), but the three gaps the issue actually
names were still untested. This closes them: 52 → 125 assertions.
_deviceIdstability across a refreshIt is documented as "deterministic (stable across refreshes)", and
PamAccess::_callerIdreturns it as the bastion identity — a device whose idchanged at refresh would silently lose its vouchers, and the symptom would show
up hours later as
voucher_mismatch.Exercised end to end through
/pam/heartbeat, the path Open Bastion actuallyuses, across two consecutive heartbeats. pam-access is pulled in as a
test-only dependency (
test_dependsinplugin.json), which the toolingalready supports and CI already honours.
The renewed token carries no
user_session_id— an offline refresh token isstandalone by design — which is precisely why
_deviceIdhas to be stable onits own: there is nothing else to key on.
AllowOffline=0+ownership=organizationAn access token is issued, no refresh token of either kind,
offline_accessis not advertised as granted, and the identity swap still happens with
_deviceIdstamped.The identity swap itself
The wholesale
%$session_data = %{ $session->data }copy and theuser_session_idreassignment:mail,cn,uid,_session_uid) survives the copy;userinfoanswers as the device and carries none of the admin's attributes;removed — the entire point of organizational ownership;
ownership=organizationgets no_deviceIdand keeps the admin identity.
One constraint pinned
The plugin's design comment dismisses the "offline refresh re-resolves the user
in the UserDB" objection on the grounds that Open Bastion never uses the core
refresh grant. Nothing checked that. It now does: the core
refresh_tokengrant answers 400
invalid_grantfor these tokens. If that ever changes,the test says so before a deployment finds out.
No production code changed, apart from the
test_dependsentry.https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL