{"record":{"id":"9d828d9e1a71b8df","repo":"elsa-workflows/elsa-core","slug":"the-external-refresh-token-cannot-be-used","errorCode":null,"errorMessage":"The external refresh token cannot be used.","messagePattern":"The external refresh token cannot be used\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs","lineNumber":54,"sourceCode":"        var rawToken = refreshToken.Reveal();\n        var separator = rawToken.IndexOf('.', StringComparison.Ordinal);\n        if (separator <= 0 || separator == rawToken.Length - 1)\n            throw new InvalidOperationException(\"The external refresh token is invalid.\");\n        var sessionId = rawToken[..separator];\n        var currentHash = Hash(rawToken);\n        var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);\n        if (session is null || !string.Equals(session.AuthenticationClientId, clientId, StringComparison.Ordinal))\n            throw new InvalidOperationException(\"The external refresh token is invalid.\");\n        var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationToken);\n        if (session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt is not null || !string.Equals(connection.Connection.MaterialRevision, session.ConnectionMaterialRevision, StringComparison.Ordinal))\n            throw new InvalidOperationException(\"The external authentication session is no longer valid.\");\n        if (!string.Equals(session.SecretGenerationFingerprint, await GetSecretFingerprintAsync(connection.Connection.SecretBindings, cancellationToken), StringComparison.Ordinal))\n            throw new InvalidOperationException(\"The external authentication session secrets changed.\");\n\n        var nextToken = CreateRefreshToken(session.Id);\n        var rotation = await sessionStore.TryRotateRefreshTokenAsync(session.Id, currentHash, session.RefreshGeneration, Hash(nextToken), clock.UtcNow, cancellationToken);\n        if (rotation is not ExternalAuthenticationSessionRotationResult.Rotated { Session: var rotated })\n            throw new InvalidOperationException(\"The external refresh token cannot be used.\");\n\n        return await IssueResponseAsync(rotated, nextToken, cancellationToken);\n    }\n\n    private async ValueTask<ExternalTokenResponse> IssueResponseAsync(ExternalAuthenticationSession session, string refreshToken, CancellationToken cancellationToken)\n    {\n        using var tenantContext = tenantAccessor.PushContext(new()\n            { Id = session.TenantId, Name = session.TenantId });\n        var user = await userProvider.FindAsync(new()\n                       { Id = session.UserId }, cancellationToken)\n            ?? throw new InvalidOperationException(\"The external authentication session user no longer exists.\");\n        var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();\n        // Role permissions go through the same deployment boundary as the external grants beside them. They\n        // used to be concatenated raw, which let a permission the boundary had just excluded during grant\n        // resolution reappear here from the same roles -- making the deny list unenforceable for anything a\n        // role happened to carry, and ElsaRolePermissionGrantSource's own filtering pointless. Re-applying it\n        // at issuance also picks up a boundary that changed since sign-in, because refreshing reissues.\n        // With no boundary configured, which is the default, every well-formed permission passes and nothing","sourceCodeStart":36,"sourceCodeEnd":72,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs#L36-L72","documentation":"RefreshAsync rotates the refresh token atomically via sessionStore.TryRotateRefreshTokenAsync, which succeeds only when the presented token's hash and the session's current RefreshGeneration match the store's state. This is thrown when the rotation fails — the presented token is not the current-generation refresh token (it was already rotated/reused), or the session row changed concurrently.","triggerScenarios":"Calling RefreshAsync with an older, already-rotated refresh token (replay after a prior refresh succeeded); two concurrent refreshes racing on the same session (one wins, the other hits a generation mismatch); store state drifted so currentHash/RefreshGeneration no longer match.","commonSituations":"Multiple app instances or tabs sharing one session each holding a copy of the refresh token and refreshing independently; a client retried a refresh after a network error without persisting the new token from the first attempt; test scenarios asserting replay revocation (RefreshRotationRevokesTheSessionWhenAnOlderTokenIsReused).","solutions":["Persist the NEW refresh token returned by each refresh immediately and atomically; never keep using the previous token.","Serialize refreshes per session: use a single point (e.g. one backend service or a per-session lock) so only one caller refreshes at a time.","After this error, the session is typically revoked by rotation-replay handling — force full re-authentication via IssueAsync.","If racing callers are unavoidable, catch this error and re-read session state; only the caller holding the current token can recover."],"exampleFix":"// before: retry reuses stale token\nasync Task<Token> Refresh() => await issuer.RefreshAsync(_refreshToken, clientId); // _refreshToken may be old\n\n// after\nprivate readonly SemaphoreSlim _refreshLock = new(1, 1);\nasync Task<Token> Refresh()\n{\n    await _refreshLock.WaitAsync();\n    try\n    {\n        var resp = await issuer.RefreshAsync(_refreshToken, clientId); // single-flight\n        _refreshToken = resp.RefreshToken; // persist new token immediately\n        return resp;\n    }\n    finally { _refreshLock.Release(); }\n}","handlingStrategy":"retry","validationCode":"// single-flight guard so only one caller refreshes per session\nprivate static readonly SemaphoreSlim RefreshLock = new(1, 1);\nawait RefreshLock.WaitAsync(cancellationToken);\ntry { return await issuer.RefreshAsync(currentRefreshToken, clientId); }\nfinally { RefreshLock.Release(); }","typeGuard":null,"tryCatchPattern":"try\n{\n    return await issuer.RefreshAsync(refreshToken, clientId);\n}\ncatch (InvalidOperationException ex) when (ex.Message == \"The external refresh token cannot be used.\")\n{\n    // token was already rotated (replay or race); session is revoked\n    ClearStoredTokens();\n    return await ReAuthenticateAsync();\n}","preventionTips":["Persist the new refresh token atomically before any other code can read the old one.","Centralize refresh in one component per session (single-flight with a lock or dedicated auth service).","Never retry a refresh with the same token after a failure — the token may have been consumed.","In multi-instance deployments, share session/token state (sticky sessions or shared store) so only one instance refreshes."],"tags":["authentication","token-refresh","race-condition","token-rotation"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"fe9217bdfa0e27f0e09e45006eb6898f616e513d","analyzedAt":"2026-09-13T20:32:34.702Z","contentChangedAt":"2026-09-13T20:32:34.702Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}