{"record":{"id":"9dfc161c123a4cdd","repo":"elsa-workflows/elsa-core","slug":"the-external-refresh-token-is-invalid","errorCode":null,"errorMessage":"The external refresh token is invalid.","messagePattern":"The external refresh token is invalid\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs","lineNumber":39,"sourceCode":"    IElsaTokenService tokenService,\n    ITenantAccessor tenantAccessor,\n    ISystemClock clock,\n    IOptions<ExternalAuthenticationOptions> options) : IExternalAuthenticationTokenIssuer\n{\n    public async ValueTask<ExternalTokenResponse> IssueAsync(ExternalAuthenticationSession session, CancellationToken cancellationToken = default)\n    {\n        var refreshToken = CreateRefreshToken(session.Id);\n        session.CurrentRefreshTokenHash = Hash(refreshToken);\n        await sessionStore.SaveAsync(session, cancellationToken);\n        return await IssueResponseAsync(session, refreshToken, cancellationToken);\n    }\n\n    public async ValueTask<ExternalTokenResponse> RefreshAsync(string clientId, SensitiveString refreshToken, CancellationToken cancellationToken = default)\n    {\n        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    }","sourceCodeStart":21,"sourceCodeEnd":57,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs#L21-L57","documentation":"DefaultExternalAuthenticationTokenIssuer.RefreshAsync validates the external refresh token format and that it maps to an existing session belonging to the given clientId. It throws when the token lacks the sessionId.hash structure, the session no longer exists, or the session's AuthenticationClientId does not match the presented clientId — typically because refresh rotation already revoked the session after reuse of an older token.","triggerScenarios":"Thrown from RefreshAsync when: (1) the raw token has no '.' or it is at position 0 or last (malformed format); (2) sessionStore.FindByIdAsync returns null (session deleted/rotated away); (3) session.AuthenticationClientId differs case-sensitively from the clientId argument.","commonSituations":"Two clients/processes racing to refresh and the second reusing the already-rotated token; stale refresh token persisted by a client across restarts; wrong clientId passed to the refresh call; corrupted or truncated token from storage; test 'RefreshRotationRevokesTheSessionWhenAnOlderTokenIsReused' exercises exactly this reuse-revocation path.","solutions":["Use the latest issued refresh token; after rotation the previous one is revoked and must be discarded.","Pass the same clientId that originally obtained the token to RefreshAsync.","Verify the stored token is complete and untruncated (format: '<sessionId>.<hash>').","If the session was rotated/revoked, re-authenticate the user to obtain a fresh token pair.","Persist the refreshed token atomically immediately after each refresh to avoid reuse races."],"exampleFix":"// before\nawait issuer.RefreshAsync(\"other-client\", staleRefreshToken); // rotated token or wrong clientId\n// after\nawait issuer.RefreshAsync(originalClientId, latestRefreshToken); // current, unrotated token","handlingStrategy":"try-catch","validationCode":"// Before calling RefreshAsync, sanity-check the token shape and clientId\nbool LooksLikeRefreshToken(string token) =>\n    !string.IsNullOrEmpty(token) && token.IndexOf('.') is > 0 and var i && i < token.Length - 1;","typeGuard":"bool IsValidRefreshTokenFormat(string? token) =>\n    !string.IsNullOrEmpty(token)\n    && token.IndexOf('.') is var i && i > 0 && i < token.Length - 1;","tryCatchPattern":"try\n{\n    var response = await issuer.RefreshAsync(clientId, refreshToken, ct);\n    // persist the new refresh token atomically here\n}\ncatch (InvalidOperationException ex) when (ex.Message.Contains(\"refresh token is invalid\"))\n{\n    // treat as session revoked: clear stored token and re-authenticate the user\n    await tokenStore.ClearAsync(userId);\n    await signInManager.ChallengeAsync();\n}","preventionTips":["Persist rotated refresh tokens atomically immediately after refresh to prevent reuse races.","Never reuse a refresh token once a refresh response has been received.","Always use the clientId that originally requested the token.","Handle this error by forcing re-authentication, not by retrying the same token."],"tags":["authentication","oauth","refresh-token","session"],"backgroundTag":"invalid-token","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"}