Visitar URL original
identity: add WebSSO authentication by rm-you · Pull Request #3941 · gophercloud/gophercloud · GitHub
Skip to content

identity: add WebSSO authentication - #3941

Draft
rm-you wants to merge 8 commits into
gophercloud:mainfrom
rm-you:feat/websso-authentication
Draft

rm-you wants to merge 8 commits into
gophercloud:mainfrom
rm-you:feat/websso-authentication

Conversation

@rm-you

@rm-you rm-you commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3615

Adds Identity v3 authentication through Keystone's browser-based WebSSO flow. Gophercloud opens the identity-provider and protocol-specific WebSSO endpoint with a loopback origin, receives the unscoped token posted by Keystone, and validates or scopes it with the existing token API.

The callback listener is context-aware and validates the request method, content type, body size, and token. Browser opening is injectable. Reauthentication and optional caching of validated unscoped tokens are supported, allowing tokens to be rescoped without repeating browser authentication.

Links to the line numbers/files in the OpenStack source code that support the code in this PR:

Supersedes #3616, which its author closed in favor of this implementation.

@github-actions github-actions Bot added edit:openstack This PR updates common OpenStack code edit:identity This PR updates identity code labels Aug 12, 2026
@cardoe

cardoe commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thanks for picking this up — I closed #3616 in favor of this and it's in much better shape than what I had. Three things from reading through it, one of which is a workflow preference rather than a defect.

1. The unscoped path will reject a 203 from Keystone. The validation GET is hand-rolled, so it gets defaultOkCodes("GET") = {200}:

} else {
client.SetToken(unscopedToken)
response, err := client.Get(ctx, client.ServiceURL("auth", "tokens"), &r.Body, &gophercloud.RequestOpts{
MoreHeaders: map[string]string{"X-Subject-Token": unscopedToken},
})
_, r.Header, r.Err = gophercloud.ParseResponse(response, err)
}

tokens.Get has carried OkCodes: []int{200, 203} for token introspection since 46d913f1d and kept it through every later refactor of that call (#1570, #1942). On a deployment that answers 203, ParseResponse sets r.Err, SetTokenAndAuthResult bails, and the whole login fails with unexpected response code: 203. The TokenID-passthrough branch added in #1752 already handles the same "validate a token I already hold" case the right way:

if opts.CanReauth() {
return fmt.Errorf("cannot use AllowReauth, when the token ID is defined and auth scope is not set")
}
v3Client.SetToken(tokenID)
result := tokens3.Get(ctx, v3Client, tokenID, nil)
if result.Err != nil {
return result.Err
}
err = client.SetTokenAndAuthResult(result)
if err != nil {
return err
}
catalog, err = result.ExtractServiceCatalog()
if err != nil {
return err

So I think this branch just wants tokens.Get(ctx, client, unscopedToken, nil) — which is also what tokencache.Load in this same PR does.

2. The scope is only validated after the browser flow completes. captureToken runs first, and ToTokenV3ScopeMap() afterward:

unscopedToken, err := captureToken(ctx, client, opts, port, timeout)
if err != nil {
r.Err = err
return
}
scope, err := opts.ToTokenV3ScopeMap()
if err != nil {
r.Err = err
return

A Scope{ProjectName: "x"} with no domain makes the user sit through the full IdP login and then fails with ProjectName requires DomainID or DomainName, throwing away a perfectly good token. Building the scope map before captureToken (or checking it in validate()) makes that a fast failure.

3. Caching the scoped token instead of the unscoped one costs a browser round trip per project. Scope is part of the cache key:

// CacheKey returns the identity-isolated cache key for opts.
func CacheKey(identityEndpoint string, opts *AuthOptions) string {
return tokencache.Key(tokencache.KeyOptions{
Flow: "websso",
Principal: opts.CacheNamespace,
IdentityEndpoint: identityEndpoint,
IdentityProvider: opts.IdentityProviderName,
Protocol: opts.Protocol,
Scope: opts.Scope,
})
}

This is a preference rather than a bug. Day to day I move across a lot of projects, and the flow I rely on today caches the unscoped token and rescopes locally per project — one browser login in the morning and none after it. Keying the cache on scope means a new browser round trip on every project switch. Would you consider caching the unscoped token and doing the rescope against that cache instead? It also makes item 2 cheaper, since an invalid scope would never cost a login.

@cardoe

cardoe commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Here's a peer change to keystoneauth: https://review.opendev.org/c/openstack/keystoneauth/+/1000759 It's still very much a WIP as I've just squashed together a number of commits in another repo and moved them over to keystoneauth and moved the tests over. Claude did that lifting so it needs to be tested.

@rm-you

rm-you commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks—these are addressed in 0302eca7. Token validation now accepts Keystone 203 responses, scope validation happens before opening the browser, and the cache stores the unscoped token for reuse across project scopes. Regression tests cover all three cases.

@rm-you
rm-you marked this pull request as ready for review August 13, 2026 07:18
@cardoe

cardoe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Ran this against a live Keystone deployment with an OIDC federated identity provider, using a small file-backed tokencache.Cache. Items 1-3 from my earlier comment all behave correctly outside the unit tests:

  • Unscoped-token reuse works as intended. One browser login, then rescoping to a second project ran with no browser flow at all. That's exactly the workflow I was after in item 3 — confirmed end to end against a real deployment, not just TestWebSSOCacheReusesUnscopedTokenAcrossProjects.
  • The capture → validate → rescope path works against live Keystone. tokencache.Validate going through tokens.Get returns the token with X-Subject-Token present, so the rescope receives a usable token ID.
  • The defaults match a real trusted_dashboard config. Callback origin http://localhost:9990/auth/websso/ worked with no server-side change on my side.

Two small notes on the new code, neither blocking:

  1. tokencache.Validate doesn't touch the cache, and Authenticate calls it on the non-cached path, so callers who never enable caching still take a dependency on the tokencache package. It might sit better unexported, or next to tokens where the rest of the token operations live.

  2. Both paths already know the unscoped token ID — captureToken returns it, and cachedToken.TokenID stores it — but then re-derive it from the validate response's X-Subject-Token header:

https://github.com/gophercloud/gophercloud/blob/784868f7297f9c58cbf9daecf4c0b5a5cc50a0ba/openstack/identity/v3/websso/requests.go#L145-L153

That works against the deployments I can test, but it's an avoidable dependency on Keystone echoing the header back; carrying the known ID forward would be more robust.

Separately, something I hit while writing the harness that isn't this PR's fault: AuthenticateV3 with a non-empty gophercloud.EndpointOpts segfaults, because v3auth hands them to NewIdentityV3, which calls the still-nil client.EndpointLocator. openstack.Authenticate always passes an empty value so nothing in-tree hits it, but it will bite anyone wiring an alternate auth flow up by hand. I'll file that on its own.

Nothing blocking from my side — thanks for turning items 1-3 around so quickly.

@rm-you

rm-you commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for testing this. Addressed both notes in 90a9b9d:

  • Token validation is now an unexported WebSSO helper; tokencache only reads and writes cached token metadata.
  • Both browser and cache paths carry the known unscoped token ID through validation and use it directly for rescoping. Regression tests cover validation responses without X-Subject-Token on both paths.

@cardoe

cardoe commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Re-tested against a live Keystone deployment with an OIDC federated IdP. Both notes from my last comment are resolved, and the caching behavior holds up well in practice:

  • One browser login, then rescoping to a second project — and to a project in a different domain — both served entirely from the cached unscoped token with no browser flow at all.
  • TestWebSSOKeepsKnownTokenIDAfterValidation is the right test for note 2: mocking a validate response that omits X-Subject-Token and still asserting the correct token ID is exactly the dependency I was worried about.
  • Moving tokencache off any gophercloud imports is a cleaner outcome than what I suggested. The package is now purely a string cache.

One thing I'd still like to see:

Close the browser window on success. The callback handler in openstack/identity/v3/websso/requests.go currently returns a page telling the user to close the window themselves ("Authentication successful ... You may close this window"). The Python WebSSO plugin I use adds a <script>window.close()</script> to the success page so the tab disappears on its own, which is a noticeably nicer CLI experience when you do this many times a day. It's best-effort — browsers refuse window.close() on tabs that script didn't open — but it costs nothing and works often enough to be worth it.

@rm-you

rm-you commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Added the best-effort window.close() call in aef252e, with the existing success message retained as the fallback when the browser refuses to close the tab.

@cardoe

cardoe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

This version looks great to me. The window.close() change is the last thing I was after, and everything I raised earlier is resolved.

Nothing outstanding from my side, and I don't want to hold this up. To be clear about my earlier comment: the acceptance-test coverage is still worth adding, but I don't think it needs to block landing — it can follow in a separate change.

Thanks for being so responsive through all of this.

@rm-you
rm-you force-pushed the feat/websso-authentication branch from aef252e to 03ffe68 Compare August 18, 2026 07:31
@coveralls

coveralls commented Aug 18, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 63.834% (+0.3%) from 63.552% — rm-you:feat/websso-authentication into gophercloud:main

@github-actions github-actions Bot added semver:minor Backwards-compatible change backport-v2 This PR will be backported to v2 labels Aug 18, 2026
@cardoe

cardoe commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

@stephenfin the Zun failure is unrelated so this can run again.

@winiciusallan

winiciusallan commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

@stephenfin the Zun failure is unrelated so this can run again.

An upstream fix has landed, so I'm rerunning the failing job. I'm also taking a look at the code right now.

@winiciusallan winiciusallan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @rm-you, thank you very much for working on this and updating the PR promptly! I've left a few comments that I judged important, let me know what you think.

@stephenfin since you were involved in the upstream review, it would be nice to have eyes on this.

return time.Now().Add(tokenExpiryMargin).Before(ct.ExpiresAt)
}

// Load retrieves a cached token ID. Missing, malformed, and expired entries are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not malformed entries return an error or at least a warning? I think it's reasonable to consider missing and/or expired as a cache miss, but a malformed entries could be a result of something wrong somewhere else in the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally made the cache best-effort here. A malformed or stale local entry should not make authentication fail when we can delete it and continue through the normal browser flow; the next successful login repairs the entry. The cache package also has no logging facility, and returning a decoding error would make an optional cache less reliable than having no cache.

I agree that malformed data is distinct from an ordinary miss, though, and I can clarify that behavior in the docs. If the preferred contract is to expose backend or decoding errors, I can change Load to return an error, but my inclination is still delete-and-miss for malformed data and to reserve errors for failures that prevent recovery.

@winiciusallan winiciusallan Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[...] A malformed or stale local entry should not make authentication fail when we can delete it and continue through the normal browser flow; the next successful login repairs the entry. [...]

This has convinced me, so I'm okay keeping as-is. But let's keep the thread open in case anyone has objections.

const tokenExpiryMargin = 5 * time.Minute

// Cache stores authentication results. Implementations must be safe for concurrent use.
type Cache interface {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I can see, we don't have any concrete implementation of this interface throughout the PR (regardless the tests).

Is this being addressed in a follow-up or is it intended to be optional and implemented by the client?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the python client uses the keyring package to store the cache in the system key chain. It's ultimately implemented by the end user client. But it would require each client to implement this. So I'm not sure how to best address this. There could be a default implementation in gophercloud? I'd be happy to do it as a follow up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a SDK point of view, we want to make the end users' life easier, so IMO it's worth having at least one basic implementation while delivering the cache benefits (it improves overall performance and avoid round tripping to keystone).

If the other people agree, I think we could do this in a follow-up PR yes 🙂.

@rm-you rm-you Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface is already consumed by WebSSO in this PR: before opening the browser, it loads and validates a cached unscoped token; if Keystone rejects that token, it deletes the entry; after a successful browser flow, it persists the newly captured token. What is not included here is a concrete persistent-storage backend.

The original version of this integration put the system-keyring implementation in gophercloud/utils, alongside the higher-level clientconfig wiring. As the rest of this work evolved, the core clouds.ParseV3 follow-up made that Utils layer unnecessary. I did not move the keyring backend into base Gophercloud because doing so would add go-keyring and its platform-specific transitive dependencies to the core module for every user. Instead, the provider integration currently supplies the implementation in internal/keyringcache and passes it through clouds.WithTokenCache.

I am not opposed to putting a concrete implementation here. If the maintainers are comfortable with those dependencies in the base Gophercloud module, I would be happy to move the keyring implementation into this PR or a small follow-up. I mainly wanted to avoid making that dependency decision implicitly.

For context, the complete dependency plan is:

  • #3939, context-aware reauthentication, is merged and is the common prerequisite.
  • #3984 adds the Identity v3 provider-construction path used by the higher-level parser.
  • This PR adds WebSSO and the generic cache contract. WebSSO is its first consumer.
  • #3942 adds OIDC client-credentials auth. Assuming the PR we're commenting on now merges first, I will update this OIDC PR to make it use the same cache contract.
  • #3940 adds OAuth2 mTLS support.
  • The unposted feat/federated-auth-clouds follow-up adds clouds.ParseV3, registers those auth methods, and replaces the remaining gophercloud/utils clientconfig role. This might be a little confusing to look at since it is currently my own kind of ... integration branch that I use for deploying, but once a couple more of these merge I will have some follow-ups. I wish GitHub would let me do stacked PRs from my fork. :(
  • garm-provider-openstack#30 is the merged Gophercloud v2 baseline. Its integration follow-up consumes clouds.ParseV3 and supplies the OS-keyring backend shown above.

Keeping the cache contract generic lets WebSSO now and OIDC later share the key format and token envelope, while leaving storage selection outside the core module unless the maintainers prefer otherwise.

Again, I am happy to add an implementation here, I was actually not happy to leave it without one either, but it felt like ya'll might balk at the added dependencies, so say the word and I'll put it in here.

@winiciusallan winiciusallan Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although the cache is a "should have" considering the performance improvements, it isn't a hard dependency and we can do it in a follow-up PR if we want, so I'll not push this too long.

Can we at least document that users need to implement this and provide one reliable source?

I will try to thoroughly review the PRs in the correct order. This seems a perfect use case for stacked PRs 🙂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I wish they worked from a fork. If I had write access to this repo I could push them all to branches here and then created stacked PRs, but they don't work from a fork to the source repo (or fork to fork). :(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I would PREFER to add the cache implementation here NOW, the blocker for me was that I thought it would not be ideal to add client dependencies here on the keyring deps... but I am totally happy to just DO IT, if you give me a green light on that (and would rather just do it than put in temporary docs).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's land this PR and let me pull it down in some local work and then stack some more PRs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I mean the real issue is that I also am waiting for about three of these to land so that I can make some follow ups that tie them all together better, I literally have some branches just sitting waiting to be able to sanely make them a PR without the ability to stack :(

return nil
}

func captureToken(ctx context.Context, client *gophercloud.ServiceClient, opts *AuthOptions, port int, timeout time.Duration) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the upstream implementation, the callback is opened to CSRF and @cardoe tried to mitigate this with some validation in a helper function.

For security reasons, wdyt of having something similar here? We're only checking method, content-type, and body size.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth noting that the CSRF is happening locally so it's not really a big danger. But I am trying to mitigate it going forward.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. The listener is restricted to localhost or a loopback address, accepts only a form POST on the callback path, limits the body size, and validates the submitted token with Keystone before using it. That keeps it from being a remotely exposed arbitrary-token endpoint, but it does not bind the callback to the browser flow that opened it. A malicious local process, or a web page able to submit a form to the loopback listener, could race the legitimate callback and cause a denial of service or, with a valid token, an authentication-confusion result.

I agree that a per-flow unpredictable nonce is worthwhile defense in depth here. I can add the equivalent binding and focused tests in this PR. To be honest, I thought I remembered already doing that, but it might have been in one of my experimental branches. I'll just go ahead and handle that now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correction after checking this while implementing it: a per-flow nonce cannot be added to the origin, because Keystone requires that value to match a trusted_dashboard entry verbatim. The current keystoneauth change documents the same limitation.

I added the equivalent request-shape hardening in 43bc756: the callback now requires top-level navigation Fetch Metadata, rejects direct navigation, and rejects mismatched Origin or Referer when those headers are available. Focused tests cover each case. This narrows what can reach token handling, but it cannot cryptographically bind the callback to the flow, so the remaining login-CSRF limitation is documented.

@rm-you

rm-you commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Yeah I'm a bit distracted this week but I'll try to look at this soon -- but at a high level, yes this is in the middle of a chain of a number of PRs both here and in GARM, so there's a good chance that things unused here are in fact for use somewhere else. I'll look at the specifics and give you some links later this week, thanks for taking a look!

Edit: I had some time earlier than expected, so see in-line comments.

@rm-you
rm-you force-pushed the feat/websso-authentication branch from 03ffe68 to 43bc756 Compare September 1, 2026 09:51
rm-you and others added 6 commits September 6, 2026 02:46
Refs gophercloud#3615

Co-authored-by: Taiwo Oyewole <taiwooyewole@geico.com>
Validate scope before browser authentication and preserve transient token validation errors. Accept Keystone 203 responses and restrict callback tokens to the form body.
Keystone cannot correlate callback POSTs with the browser flow that initiated them. Document the login-CSRF exposure and cache identity behavior, and cover listener ordering and namespace isolation.
@rm-you
rm-you force-pushed the feat/websso-authentication branch from 4582477 to e2780e9 Compare September 6, 2026 07:51
rm-you added a commit to rm-you/gophercloud that referenced this pull request Sep 8, 2026
@rm-you

rm-you commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Applied the same shared-validation cleanup as #3942 in d053736, retaining the WebSSO-specific safety checks and excluding browser/cache hooks from JSON serialization. Added regression coverage for both entry points and hook handling; full race tests and lint pass on Go 1.25.0.

@stephenfin stephenfin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review. I need to pull this down locally and take a second swing at it.

@@ -0,0 +1,99 @@
// Package tokencache provides token caching for Identity v3 authentication.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is where our previously discussed lack of a keystoneauth equivalent comes back to bite us. Does this file belong here? Typically we only modules corresponding to APIs in openstack/<service>/<version> packages.

Would it be possible to have this (and it's test) in e.g. openstack/auth instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, or just auth/ which #4008 introduced (not sure whether that needs to move or not)

Comment on lines +94 to +95
// Authenticate performs browser-based WebSSO authentication.
func Authenticate(ctx context.Context, client *gophercloud.ServiceClient, builder tokens.AuthOptionsBuilder) (r tokens.CreateResult) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely feel like this doesn't belong here. I had a look for some of our more unusual existing auth mechanisms and found openstack/identity/v3/oauth1/doc.go. I note that that uses the more standard AuthenticateV3 function from openstack/client.go. I'm wondering if this PR ought to hang off of the pluggable auth PR (which I'm yet to review also). I need to think about this one...

Later: Maybe if we called this "Create" we could deal with this for now?

Suggested change
// Authenticate performs browser-based WebSSO authentication.
func Authenticate(ctx context.Context, client *gophercloud.ServiceClient, builder tokens.AuthOptionsBuilder) (r tokens.CreateResult) {
// Create performs browser-based WebSSO authentication.
func Create(ctx context.Context, client *gophercloud.ServiceClient, opts tokens.AuthOptionsBuilder) (r tokens.CreateResult) {

It's layering on more c***, but at least it's consistent c***?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just going to refactor completely on #4008

@rm-you

rm-you commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

I think you are correct that this and the other methods should just be on top of #4008 so let's get that moving 😇

@rm-you
rm-you marked this pull request as draft September 25, 2026 19:47
@rm-you

rm-you commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Converting this to draft for now. As discussed above, this is being rebuilt on the pluggable auth package from #4008 and depends on its API, so there isn't much more we can usefully do here until #4008 merges. I'll update this PR once it does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v2 This PR will be backported to v2 edit:identity This PR updates identity code edit:openstack This PR updates common OpenStack code semver:minor Backwards-compatible change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support the websso keystone authentication method

5 participants