Silent sign-in with oidc-client.js causes timeout issues with some IdPs
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 281
Description
Original posted by @Herdo:
https://github.com/dotnet/aspnetcore/issues/40764#issuecomment-1567061660
The current implementation with iframes and silent signin based on _oidc-client-js_ is causing some timeout issues with third-party IdPs. I've described the cause of this issue in this Stackoverflow question: [Blazor WASM - Spending a long time initially in Authorizing component](https://stackoverflow.com/questions/74959385/blazor-wasm-spending-a-long-time-initially-in-authorizing-component). I'll add my analysis results below.
IMHO, the new solution should allow more freedom for cases like this, where you cannot influence IdP configuration like `X-Frame-Options` header.
## Source of issue
The issue is caused by a timeout in the underlying implementation of the authentication services. I traced down the source, but there's no easy solution to this issue.
If you enable Debug tracing for your WASM client, you should see this log message in the console:
> dbug: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0]
Initial silent sign in failed 'Frame window timed out'
For me - using Keycloak (instead of Auth0), and Discord as IdP behind Keycloak - the Discord login cannot be framed in the hidden iframe:
> Refused to frame 'https://discord.com/' because it violates the following Content Security Policy directive: "frame-src 'self' my.domain.com".
Of course this policy can be modified to include `discord.com`, but Discord denies being embedded that way with `X-Frame-Options` header.
## What's happening
1. The app gets loaded
2. `AuthorizeViewCore` is being rendered, entering [`OnParametersSetAsync`][1]:
```cs
// Clear the previous result of authorization
// This will cause the Authorizing state to be displayed until the authorization has been completed
isAuthorized = null;
currentAuthenticationState = await AuthenticationState;
isAuthorized = await IsAuthorizedAsync(currentAuthenticationState.User);
```
3. The `AuthenticationState` is initialized by [`RemoteAuthenticationService.GetAuthenticationStateAsync`][2]:
```cs
new AuthenticationState(await GetUser(useCache: true));
```
4. This will invoke `GetAuthenticatedUser`:
```cs
///
/// Gets the current authenticated used using JavaScript interop.
///
/// A that will return the current authenticated user when completes.
protected internal virtual async ValueTask GetAuthenticatedUser()
{
await EnsureAuthService();
var account = await JsRuntime.InvokeAsync("AuthenticationService.getUser");
var user = await AccountClaimsPrincipalFactory.CreateUserAsync(account, Options.UserOptions);
return user;
}
```
5. [`AuthenticationService.getUser`][3] will invoke `trySilentSignIn`:
```js
async trySilentSignIn() {
if (!this._intialSilentSignIn) {
this._intialSilentSignIn = (async () => {
try {
this.debug('Beginning initial silent sign in.');
await this._userManager.signinSilent();
this.debug('Initial silent sign in succeeded.');
} catch (e) {
if (e instanceof Error) {
this.debug(`Initial silent sign in failed '${e.message}'`);
}
// It is ok to swallow the exception here.
// The user might not be logged in and in that case it
// is expected for signinSilent to fail and throw
}
})();
}
return this._intialSilentSignIn;
}
```
6. The `await this._userManager.signinSilent();` will invoke the **oidc-client-js** [UserManager][4] `signinSilent` and then `_signinSilentIframe`:
```js
_signinSilentIframe(args = {}) {
let url = args.redirect_uri || this.settings.silent_redirect_uri || this.settings.redirect_uri;
if (!url) {
Log.error("UserManager.signinSilent: No silent_redirect_uri configured");
return Promise.reject(new Error("No silent_redirect_uri configured"));
}
args.redirect_uri = url;
args.prompt = args.prompt || "none";
return this._signin(args, this._iframeNavigator, {
startUrl: url,
silentRequestTimeout: args.silentRequestTimeout || this.settings.silentRequestTimeout
}).then(user => {
if (user) {
if (user.profile && user.profile.sub) {
Log.info("UserManager.signinSilent: successful, signed in sub: ", user.profile.sub);
}
else {
Log.info("UserManager.signinSilent: no sub");
}
}
return user;
});
}
```
7. Finally, this will end up at [`IFrameWindow.js`][5], which has **a timeout of 10000 ms** configured:
```js
const DefaultTimeout = 10000;
```
8. The initially logged **timeout error** is thrown:
```js
_timeout() {
Log.debug("IFrameWindow.timeout");
this._error("Frame window timed out");
}
```
[1]: https://github.com/dotnet/aspnetcore/blob/d0e94423f0ef587b8fe262667c8168e14e4f5ac7/src/Components/Authorization/src/AuthorizeViewCore.cs#LL71C5-L93C1
[2]: https://github.com/dotnet/aspnetcore/blob/d0e94423f0ef587b8fe262667c8168e14e4f5ac7/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/RemoteAuthenticationService.cs#LL104C1-L104C141
[3]: https://github.com/dotnet/aspnetcore/blob/d0e94423f0ef587b8fe262667c8168e14e4f5ac7/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/AuthenticationService.ts#LL141C5-L150C6
[4]: https://github.com/IdentityModel/oidc-client-js/blob/dev/src/UserManager.js
[5]: https://github.com/IdentityModel/oidc-client-js/blob/dev/src/IFrameWindow.js
Contributor guide
Research direction
Start with AuthorizeViewCore.cs, RemoteAuthenticationService.cs, and AuthenticationService.ts, then review the referenced oidc-client-js UserManager.js and IFrameWindow.js behavior. Define how silent sign-in should work when an IdP rejects iframe embedding, and verify that initial authorization no longer waits for the frame timeout.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, javascript, typescript
- Domain
- authentication, frontend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100