-
Notifications
You must be signed in to change notification settings - Fork 2.8k
WebSocket Next: enable users to update SecurityIdentity before previous bearer access token expires #47675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
WebSocket Next: enable users to update SecurityIdentity before previous bearer access token expires #47675
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1073,6 +1073,51 @@ When you plan to use bearer access tokens during the opening WebSocket handshake | |
* Use a custom WebSocket ticket system which supplies a random token with the HTML page which hosts the JavaScript WebSockets client which must provide this token during the initial handshake request as a query parameter. | ||
==== | ||
|
||
Before the bearer access token sent on the initial HTTP request expires, you can send a new bearer access token as part of a message and update current `SecurityIdentity` attached to the WebSocket server connection: | ||
|
||
[source, java] | ||
---- | ||
package io.quarkus.websockets.next.test.security; | ||
|
||
import io.quarkus.security.Authenticated; | ||
import io.quarkus.security.identity.SecurityIdentity; | ||
import io.quarkus.websockets.next.OnTextMessage; | ||
import io.quarkus.websockets.next.WebSocket; | ||
import io.quarkus.websockets.next.WebSocketSecurity; | ||
import jakarta.inject.Inject; | ||
|
||
@Authenticated | ||
@WebSocket(path = "/end") | ||
public class Endpoint { | ||
|
||
record RequestDto(String accessToken, String message) {} | ||
|
||
@Inject | ||
SecurityIdentity securityIdentity; | ||
|
||
@Inject | ||
WebSocketSecurity webSocketSecurity; | ||
|
||
@OnTextMessage | ||
String echo(RequestDto request) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this method will have to match the security level of the HTTP upgrade. For example, if HTTP Upgrade happened at There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, that's easy to do. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @michalvavrik Sounds good, we do it indirectly in the code flow too when refreshing tokens, since it is done in scope of the request. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure if I understand what you mean, but my plan is: there is a check that assures principal name is same (so they can't change alice for bob), but for roles and permissions, we just need to reapply standard security annotations (HTTP policies sounds like out of scope to me), which I think is easy. There is a code that prevents repeated security checks, I'll just disable the code when the identity refresh is enabled. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @michalvavrik I was actually thinking about it too, how do you prevent an identity swap, so having the principal name check is good, but a few more checks may be necessary, such as a IMHO, whatever the security constraints were applied to HTTP upgrade should be reapplied at the token injection time because this is how we do in the code flow refresh. For example, when the ID and access token are refreshed, the refreshed ID token goes through the complete authentication and authorization cycle before the method can be called. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the token is binary then we can update OidcIdentityProvider to record |
||
if (request.accessToken != null) { | ||
webSocketSecurity.updateSecurityIdentity(request.accessToken); <1> | ||
} | ||
String principalName = securityIdentity.getPrincipal().getName(); <2> | ||
return request.message + " " + principalName; | ||
} | ||
|
||
} | ||
---- | ||
<1> Asynchronously update the `SecurityIdentity` attached to the WebSocket server connection. | ||
<2> The `SecurityIdentity` instance injected into the `Endpoint` will represent the updated identity after Quarkus has finished the asynchronous identity update. | ||
The update process should be imperceptible, because the `SecurityIdentity` before and after update should only differ in the token expiration time. | ||
|
||
The xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer token authentication] mechanism has builtin support for the `SecurityIdentity` update. | ||
If you use other authentication mechanisms, you must implement the `io.quarkus.security.identity.IdentityProvider` provider that supports the `io.quarkus.websockets.next.runtime.spi.telemetry.WebSocketIdentityUpdateRequest` authentication request. | ||
|
||
IMPORTANT: Always use the `wss` protocol to enforce encrypted HTTP connection via TLS when sending credentials as part of the WebSocket message. | ||
|
||
=== Inspect and/or reject HTTP upgrade | ||
|
||
To inspect an HTTP upgrade, you must provide a CDI bean implementing the `io.quarkus.websockets.next.HttpUpgradeCheck` interface. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
package io.quarkus.oidc.runtime; | ||
|
||
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute; | ||
|
||
import java.util.HashMap; | ||
import java.util.Map; | ||
import java.util.function.Supplier; | ||
|
||
import io.quarkus.arc.Arc; | ||
import io.quarkus.oidc.AccessTokenCredential; | ||
import io.quarkus.oidc.OIDCException; | ||
import io.quarkus.oidc.TenantIdentityProvider; | ||
import io.quarkus.security.identity.AuthenticationRequestContext; | ||
import io.quarkus.security.identity.SecurityIdentity; | ||
import io.quarkus.security.identity.request.TokenAuthenticationRequest; | ||
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; | ||
import io.smallrye.mutiny.Uni; | ||
import io.vertx.ext.web.RoutingContext; | ||
|
||
final class TenantSpecificOidcIdentityProvider extends OidcIdentityProvider implements TenantIdentityProvider { | ||
|
||
private final String tenantId; | ||
private final BlockingSecurityExecutor blockingExecutor; | ||
|
||
TenantSpecificOidcIdentityProvider(String tenantId, DefaultTenantConfigResolver resolver, | ||
BlockingSecurityExecutor blockingExecutor) { | ||
super(resolver, blockingExecutor); | ||
this.blockingExecutor = blockingExecutor; | ||
this.tenantId = tenantId; | ||
} | ||
|
||
TenantSpecificOidcIdentityProvider(String tenantId) { | ||
this(tenantId, Arc.container().instance(DefaultTenantConfigResolver.class).get(), | ||
Arc.container().instance(BlockingSecurityExecutor.class).get()); | ||
} | ||
|
||
@Override | ||
public Uni<SecurityIdentity> authenticate(AccessTokenCredential token) { | ||
return authenticate(new TokenAuthenticationRequest(token)); | ||
} | ||
|
||
@Override | ||
protected Uni<TenantConfigContext> resolveTenantConfigContext(TokenAuthenticationRequest request, | ||
AuthenticationRequestContext context) { | ||
return tenantResolver.resolveContext(tenantId).onItem().ifNull().failWith(new Supplier<Throwable>() { | ||
@Override | ||
public Throwable get() { | ||
return new OIDCException("Failed to resolve tenant context"); | ||
} | ||
}); | ||
} | ||
|
||
@Override | ||
protected Map<String, Object> getRequestData(TokenAuthenticationRequest request) { | ||
RoutingContext context = getRoutingContextAttribute(request); | ||
if (context != null) { | ||
return context.data(); | ||
} | ||
return new HashMap<>(); | ||
} | ||
|
||
private Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest request) { | ||
return authenticate(request, new AuthenticationRequestContext() { | ||
@Override | ||
public Uni<SecurityIdentity> runBlocking(Supplier<SecurityIdentity> function) { | ||
return blockingExecutor.executeBlocking(function); | ||
} | ||
}); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
package io.quarkus.oidc.runtime; | ||
|
||
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute; | ||
|
||
import jakarta.enterprise.context.ApplicationScoped; | ||
import jakarta.inject.Inject; | ||
|
||
import io.quarkus.oidc.AccessTokenCredential; | ||
import io.quarkus.oidc.OidcTenantConfig; | ||
import io.quarkus.security.AuthenticationFailedException; | ||
import io.quarkus.security.identity.AuthenticationRequestContext; | ||
import io.quarkus.security.identity.IdentityProvider; | ||
import io.quarkus.security.identity.SecurityIdentity; | ||
import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; | ||
import io.quarkus.websockets.next.runtime.spi.telemetry.WebSocketIdentityUpdateRequest; | ||
import io.smallrye.mutiny.Uni; | ||
import io.vertx.ext.web.RoutingContext; | ||
|
||
@ApplicationScoped | ||
public class WebSocketIdentityUpdateProvider implements IdentityProvider<WebSocketIdentityUpdateRequest> { | ||
|
||
@Inject | ||
DefaultTenantConfigResolver resolver; | ||
|
||
@Inject | ||
BlockingSecurityExecutor blockingExecutor; | ||
|
||
WebSocketIdentityUpdateProvider() { | ||
} | ||
|
||
@Override | ||
public Class<WebSocketIdentityUpdateRequest> getRequestType() { | ||
return WebSocketIdentityUpdateRequest.class; | ||
} | ||
|
||
@Override | ||
public Uni<SecurityIdentity> authenticate(WebSocketIdentityUpdateRequest request, | ||
AuthenticationRequestContext authenticationRequestContext) { | ||
return authenticate(request.getCredential().getToken(), getRoutingContextAttribute(request)); | ||
} | ||
|
||
private Uni<SecurityIdentity> authenticate(String accessToken, RoutingContext routingContext) { | ||
final OidcTenantConfig tenantConfig = routingContext.get(OidcTenantConfig.class.getName()); | ||
if (tenantConfig == null) { | ||
return Uni.createFrom().failure(new AuthenticationFailedException( | ||
"Cannot update SecurityIdentity because OIDC tenant wasn't resolved for current WebSocket connection")); | ||
} | ||
final var tenantId = tenantConfig.tenantId().get(); | ||
final var identityProvider = new TenantSpecificOidcIdentityProvider(tenantId, resolver, blockingExecutor); | ||
final var credential = new AccessTokenCredential(accessToken); | ||
return identityProvider.authenticate(credential); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How is this new token can be obtained ? The current one is bound to the connection at the upgrade time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How did you obtained original token used at the upgrade time? Obtain it the same way. You pass it in a DTO that contains message and optionally metadata. You can pass it into the endpoint or hide that in the decoder (but I prefer the endpoint, hence the example). Tests speak for itself
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can front end client use refresh token stored in a session storage and obtain access token?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@michalvavrik
I'm assuming SPA used the authorization code flow
I don't think it works the same way for the token refresh.
So the proposal is for SPA to run a background task and check the current access token expiry time and refresh ?
What I don't know if OIDC scripts allow SPA access the access token expiry time - as some of these tokens are binary tokens.
The other thing I don't know, can one do HTTP calls while working with WS API ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know how to make WS work with authorization code flow on the Backend. I have looked into it before and I also raised it in the linked issue comments. Even if authorization code flow started before the WebSocket was opened, you still don't have a have to link session cookie with opened WebSocket. Anyway, I opened this PR for a bearer token authentication only.
[1]
I need more information to understand why not, I can trust you, but then there is no point continue because this PR just won't do. No point wasting time on a review. My thinking was that:
proposal is for SPA to get access token itself in a ways described right above, please see
[1]
of this comment.I have no idea about that, but I remember that in our SPA we got tokens from Keycloak and we knew token expiration time (I want to say 30 minutes for access token and 18 hours for the refresh token, but honestly, I am not sure if I am not imaging it). It didn't change for us, maybe they can just check it out before? E.g. they don't exactly need to automate it, just add timed task.
Typescript has multithreading, I think having background tasks like updating feeds on your SPA is a standard (e.g. on the newspage you have breaking news and that can by done by polling and HTTP requests). But I do speak about frameworks like Angular and React, no idea what you do with vanilla JS or node.js etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@michalvavrik Sure, SPA can do token refreshes
Can it really work though such that the script that works with the current WS connection can accept a refreshed token and forward this token to Quarkus ?
If we could confirm it then it would be great, IMHO there is no rush with the fix being merged
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll write an example by the end of the week.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have more plans for WS Next and while I didn't mention it (no reason to mention it), what I have added to this PR will allow me to easily fix quarkiverse/quarkus-langchain4j#1418 in the follow up. I don't want to keep this PR on hold, but let's wait until I have the example front end application. I'll inform you when I am done.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@michalvavrik Thanks, IMHO it will be worth it because this sample SPA can be used as a base for Step-Up authentication demos.
We can also add some simple example, similar to https://quarkus.io/guides/security-oidc-bearer-token-authentication#single-page-applications, without expecting it to be complete, to give users an idea how a token can be passed over to the existing WS connection
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, I'll look into it, but no promises on the https://quarkus.io/guides/security-oidc-bearer-token-authentication#single-page-applications part because it may require complex stuff, I don't know until I do it