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

Refactor external auth providers to re-generate headers on demand #6687

Merged
merged 2 commits into from
Jan 21, 2025

Conversation

pkukielka
Copy link
Contributor

@pkukielka pkukielka commented Jan 17, 2025

Changes

That PR eliminates annoying chat reload upon an token refresh if external auth provider is used.
It does so by splitting external auth provider evaluation into two steps which happens at different stages: config resolution and http request building.

At config resolution we only evaluate config itself and prepare a function which will be later used to generate auth headers later. That function (getHeaders) is then used to obtain auth headers every time we are doing an authorised http request. Normally headers are cached, but if they expire getHeaders should internally refresh the cache and return an updated headers. If updated headers are still expired error will be shown to the user.

In opposition to the previous solution errors in executing external provider command, or token expiration, does not lead
to the config invalidation - config always remains the same unless changed by the user. The only thing which changes is result of getHeaders functions.
Errors from getHeaders are processed and handled in the same way as e.g. Network Error or Invalid Access Token, so we do not need custom code to handle them.

image
image

Test plan

Setup

Set cody.auth.externalProviders config to use provider which support credentials expiration.
You can use configuration shown bellow.
Please also make sure you have proxy running and your sourcegraph server have http auth proxy enabled, as described in #6526.

{
    "cody.auth.externalProviders": [
        {
            "endpoint": "http://localhost:7777",
            "executable": {
                "commandLine": ["/Users/pkukielka/Work/sourcegraph/cody2/agent/scripts/simple-external-auth-provider.py"],
                "shell": "/bin/bash",
            }
        }
    ],
    
    "cody.override.serverEndpoint": "http://localhost:7777",
}

Scenario 1:

  1. Start cody, if you used simple-external-auth-provider.py should be successfully signed-in as someuser
  2. Test chat and autocompletions - everything should work
  3. Open simple-external-auth-provider.py and change current_epoch = int(time.time()) + 30 to current_epoch = int(time.time()) - 30
  4. Wait 30 sec and do some Cody action, e.g. ask a chat question
  5. You should get an error, but your conversation should be preserved
  6. Revert your changes to simple-external-auth-provider.py
  7. Ask the question again - chat should be working again

Scenario 2:

  1. Start cody, if you used simple-external-auth-provider.py should be successfully signed-in as someuser
  2. Test chat and autocompletions - everything should work
  3. Open simple-external-auth-provider.py and change current_epoch = int(time.time()) + 30 to current_epoch = int(time.time()) - 30
  4. Quickly do some Cody actions before 30 sec will pass.
    They should be successful, but when 30 sec will pas since last action before the simple-external-auth-provider.py edit, you should get an credential expiration error
  5. Revert your changes to simple-external-auth-provider.py
  6. Make sure Cody works fine again

Scenario 3:

  1. Open simple-external-auth-provider.py and change current_epoch = int(time.time()) + 30 to current_epoch = int(time.time()) - 30
  2. Start Cody
  3. You should see a credential expiration error
  4. Click 'Sign In' - it should try to sign you in but eventually fail
  5. Revert changes to simple-external-auth-provider.py
  6. Click 'Sign In' - it should succeed
  7. Make sure Cody chat and autocompletions works

@@ -23,8 +23,7 @@ export interface AuthCredentials {

export interface HeaderCredential {
// We use function instead of property to prevent accidential top level serialization - we never want to store this data
getHeaders(): Record<string, string>
expiration: number | undefined
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing it in the API only complicates the code, we don't need it there.

@@ -33,28 +37,43 @@ export class OpenTelemetryService {

constructor() {
this.configSubscription = combineLatest(
externalAuthRefresh,
Copy link
Contributor Author

@pkukielka pkukielka Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little hack, as CodyTraceExport does not expose API which would allow us to add custom headers per request, so we need to create new instance of it every time headers changes.

Changes are simple though, diff is big due to indentation change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is not watertight. We could leak the credentials from site X to trace provider for site Y if the external auth refresh and resolved config timings are out of step.

Also because the external auth refresh pings for changes but doesn't carry the value (which seems good, it will catch up immediately) if there's some rapid changes to auth, maybe things can get out of step more easily.

Could you drop a TODO here and point to https://linear.app/sourcegraph/project/cody-auth-state-rewritecleanup-7ac1409cc579/overview or a task within it, that this state should be joined?

Copy link
Contributor Author

@pkukielka pkukielka Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I will add a TODO!
On a good side I do not think we would leak anything either way because I added safeguard to the function sending headers:

export function addAuthHeaders(auth: AuthCredentials, headers: Headers, url: URL): void {
    // We want to be sure we sent authorization headers only to the valid endpoint
    if (auth.credentials && url.host === new URL(auth.serverEndpoint).host) {

So in worst case request won't pass because of missing credentials, but it will not leak them.
But I agree it's a problem regardless.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice, I missed that guard. 👍

@pkukielka pkukielka force-pushed the pkukielka/refactor-ext-auth-provider branch 2 times, most recently from c9d62f0 to 1027505 Compare January 20, 2025 08:52
Copy link
Contributor

@dominiccooney dominiccooney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some feedback inline.

return credentials
}

async function createTokenCredentails(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function createTokenCredentails(
async function createTokenCredentials(


return undefined
function createHeaderCredentails(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function createHeaderCredentails(
function createHeaderCredentials(


export class ExternalProviderAuthError extends Error {}

export function isExternalProviderAuthError(error: unknown): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a type predicate so the result of this check flows into TypeScript type inference, see https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates

Suggested change
export function isExternalProviderAuthError(error: unknown): boolean {
export function isExternalProviderAuthError(error: unknown): error is ExternalAuthProvider {

@@ -33,28 +37,43 @@ export class OpenTelemetryService {

constructor() {
this.configSubscription = combineLatest(
externalAuthRefresh,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is not watertight. We could leak the credentials from site X to trace provider for site Y if the external auth refresh and resolved config timings are out of step.

Also because the external auth refresh pings for changes but doesn't carry the value (which seems good, it will catch up immediately) if there's some rapid changes to auth, maybe things can get out of step more easily.

Could you drop a TODO here and point to https://linear.app/sourcegraph/project/cody-auth-state-rewritecleanup-7ac1409cc579/overview or a task within it, that this state should be joined?

async getHeaders() {
try {
if (!_headersCache || hasExpired((await _headersCache)?.expiration)) {
_headersCache = getExternalProviderHeaders(externalProvider)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't right. You can have two race in here and both call getExternalProviderHeaders one after the other.

Typical pattern would be to check the Promise you awaited is the one you're resetting, something like:

while (true) {
  const observed = _headersCache;
  if (!observed || hasExpired(await observed)?.expiration) {
    if (observed !== _headersCache) {
      continue;   // retry
    }
    observed = _headersCache = getExternalProviderHeaders(externalProvider);
  }
  return (await observed).headers;
}

throw new Error(`Output of the external auth command is invalid: ${result}`)
}

if (credentials.expiration && hasExpired(credentials.expiration)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasExpired already handles undefined as "not expired" so you can simplify this to skip credentials.expiration &&

@pkukielka pkukielka force-pushed the pkukielka/refactor-ext-auth-provider branch from 1027505 to f089485 Compare January 21, 2025 09:52
@pkukielka pkukielka force-pushed the pkukielka/refactor-ext-auth-provider branch from f089485 to 9831df6 Compare January 21, 2025 09:54
Copy link
Contributor

@dominiccooney dominiccooney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splendid!

@@ -33,28 +37,43 @@ export class OpenTelemetryService {

constructor() {
this.configSubscription = combineLatest(
externalAuthRefresh,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice, I missed that guard. 👍

@pkukielka pkukielka merged commit 03c93f9 into main Jan 21, 2025
20 of 21 checks passed
@pkukielka pkukielka deleted the pkukielka/refactor-ext-auth-provider branch January 21, 2025 13:28
umpox added a commit that referenced this pull request Jan 24, 2025
umpox added a commit that referenced this pull request Jan 24, 2025
umpox added a commit that referenced this pull request Jan 27, 2025
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

Successfully merging this pull request may close these issues.

2 participants