Skip to content
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

Restore connection between client and ServiceWorker after failures #1397

Open
wermanoid opened this issue Jul 1, 2024 · 4 comments
Open

Comments

@wermanoid
Copy link
Contributor

Issue and Steps to Reproduce

You need to run some auth mock server to control responses.

  • Open app and authenticate
  • Open 2 more tabs (at least 3 tabs in total is needed)
  • Simulate some authentication failure (like 400 / 401 response, but only for 1 call)
  • Result differs between tabs:
    • oidc client in 1st tab will receive error and crash
    • oidc client in 2nd tab will lose connection to SW and will hang with outdated tokens stored internally
    • oidc client in 3rd tab will raise new SW and try to login/refresh tokens
  • Now open 4th tab and pass login again, so SW here will receive valid tokens

Versions

"@axa-fr/oidc-client": "7.22.8",

Expected

If I open one more tab and login correctly, old tabs should update with correct tokens

Actual

2 tabs are broken, 1 is sometimes functional

Additional Details

I got a workaround, like next:

Tab that hangs with outdate tokens:

  • app is subscribed for token_timer event in oidc
  • every tick of token_timer reset some internal timer (2s timeout)
  • in case if SW died, token_timer is not raised any more
  • timeout creates interval with oidc.renewTokensAsync() calls, like:
intervalId = setInterval(() => {
    // actually, new tokens are returned in promise, type in oidc is not correct
        oidc.renewTokensAsync()
            .then(success => {
                if ((success as unknown) && intervalId) {
                    clearInterval(intervalId);
                }
            })
            .catch(err =>
                log.error(`Recovery failed. OIDC refresh tokens error: ${err.toString()}`));
}, 3000);
  • in case if oidc failed after error response:
            intervalId = setInterval(() => {
                oidc.tryKeepExistingSessionAsync()
                    .then(success => {
                        if (success && intervalId) {
                            clearInterval(intervalId);
                        }
                    })
                    .catch(err =>
                        log.error(
                            `Recovery failed. OIDC refresh session error: ${err.toString()}`
                        )
                    );
            }, 3000);

This case happens when oidc emits error events, like:

  • loginAsync_error
  • loginCallbackAsync_error
  • refreshTokensAsync_error
  • refreshTokensAsync_silent_error
  • silentLoginAsync_error

I have no ideas why there is such difference in renewTokensAsync and tryKeepExistingSessionAsync. Just found manually, that these functions helps to recover client in apps, when auth was renewed in some new tab.

Probably it would be a good feature to have smth like that in OIDC client by default? So OIDC client in apps could recover automatically, in case if SW was recreated/updated by working or new tab.

@guillaume-chervet
Copy link
Contributor

hi @wermanoid thank you for your issue.

I'am not sure to understand what you did but I'am interested to understand well.
Do you have a full exemple of code or something like that ?

@wermanoid
Copy link
Contributor Author

wermanoid commented Jul 4, 2024

Hi @guillaume-chervet,

There is no special app code example here, because in code it looks like regular oidc client usage.
Whole magic happened when auth server fails (like session ended/lost or server errors)

I was using oauth-mock-server to simulate some errors

here is implementation:

// index.ts
import { OAuth2Server } from 'oauth2-mock-server';

import { createStateServer } from './state-manager';

// docs https://github.com/axa-group/oauth2-mock-server/blob/master/README.md

const run = async () => {
    const { state, responseByResultHandlers } = createStateServer(); // http://localhost:8081
    // unfortunately OAuth2Server does not give access to express app instance, so I had to find a way, that allow to modify
    // auth responses in runtime, without instant server restarts. This can be achieved by simple get requests from browser
    // based on defined query params

    const server = new OAuth2Server();

    // Generate a new RSA key and add it to the keystore
    await server.issuer.keys.generate('RS256');

    // Start the server
    await server.start(8082, 'localhost');

    console.log('Issuer URL:', server.issuer.url); // -> http://localhost:8082

    server.service.on('beforeUserinfo', userInfoResponse => {
        userInfoResponse.body = {
            ...userInfoResponse.body,
            email: '[email protected]',
            name: 'JD',
        };
    });

    server.service.on('beforeTokenSigning', (token, req) => {
        const timestamp = Math.floor(Date.now() / 1000);

        token.payload.exp = timestamp + state.expireTimeSec;
        token.payload.scope =
            'offline_access groups tenant_id openid profile email';
        token.payload.scp = token.payload.scope.split(' ');

        req.body.scope = 'offline_access groups tenant_id openid profile email';
    });

    server.service.on('beforeResponse', response => {
        console.log('Response of type:', state.currentResponse);
        // modify token response based on predefined state, so we could modify desired responses in runtime, when needed
        responseByResultHandlers[state.currentResponse](response);
    });
};

run();

and code to manage responses in real-time:

import express from 'express';

let requestCounter = 0;

const state = {
    expireTimeSec: 180,
    currentResponse: 'e3', // every third token request will fail with random 400 or 401 error
};

const responseByResultHandlers = {
    e3: (response: any) => {
        if (requestCounter < 2) {
            responseByResultHandlers[200](response);
            requestCounter += 1;
        } else {
            responseByResultHandlers[Math.random() > 0.5 ? 401 : 400](response);
            requestCounter = 0;
        }
    },
    // always success
    200: (response: any) => {
        response.body.expires_in = state.expireTimeSec;
        response.body.scope =
            'offline_access groups tenant_id openid profile email';
        response.body.issued_at = response.body.issuedAt;
    },
    // always invalid_grant
    400: (response: any) => {
        response.body = {
            error: 'invalid_grant',
        };
        response.statusCode = 400;
    },
    // always unauthorized
    401: (response: any) => {
        response.body.error = 'Unauthorized';
        response.body.error_message = 'User is unauthorized';
        response.statusCode = 401;
    },
} as const;

export const createStateServer = (
    port = 8081
): {
    state: typeof state;
    responseByResultHandlers: typeof responseByResultHandlers;
} => {
    const app = express();

    app.get('/config', (req, res) => {
        state.expireTimeSec = Number(req.query?.expireTime) || 180;
        state.currentResponse = String(req.query?.responseType) || 'e3';

        console.log('New state:', state);

        return res.json({ ok: true });
    });

    app.listen(port, () => {
        console.log(
            `Auth mock configuration api runnin at http://localhost:${port}`
        );
    });

    return { state, responseByResultHandlers };
};

App to test - anything with oidc client that expects to update tokens on regular basis via SW. I've used offline_access in scope for client initialization in app. And tokens expiry is reduced to 180 sec oob, so there is no need to wait hours until oidc decided to refresh tokens.

So when you have few tabs (at least 3) opened and they all are connected to same SW everything is ok, until auth server returns first failed request.

Hope that helps.

@wermanoid
Copy link
Contributor Author

wermanoid commented Jul 5, 2024

Interesting extension related to error handling in general, a bit off-topic, but:
In case if /.well-known/openid-configuration happens to fail any time, client crashes so hard, that it fails into infinite loop
and does not recover even if auth server is back to live. This can even consume whole PC RAM and require system restart 🤣 (which I had once, actually).

image

@guillaume-chervet
Copy link
Contributor

Thank you @wermanoid it is a very nice feedback. I will look at what i can do about it !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants