Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/CLAUDE-KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,6 @@ A: Project config overrides only support the hosted `sourceOfTruth` shape. Legac

## Q: How should managed email onboarding e2e tests wait for mock verification?
A: Do not rely on a fixed `wait(1500)` after setup. The mock onboarding path flips the domain to `verified` asynchronously through `runAsynchronously`, so tests should poll the managed-onboarding check endpoint until the expected status appears.

## Q: How should Microsoft inner OAuth callback token exchanges include scopes?
A: Microsoft can reject the authorization-code token exchange with `AADSTS70011` if no `scope` parameter is sent. Keep this provider-specific by threading `providerScope` from `OAuthOuterInfo` into `OAuthBaseProvider.getCallback`, and opt Microsoft into adding the merged base and provider scope through `CallbackExtras.exchangeBody.scope`; do not add token-exchange scope globally for every provider.
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ const handler = createSmartRouteHandler({
callbackResult = await providerObj.getCallback({
codeVerifier: innerCodeVerifier,
state: innerState,
extraScope: providerScope,
callbackParams: {
...query,
...body,
Expand Down
23 changes: 22 additions & 1 deletion apps/backend/src/oauth/providers/base.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getOAuthAccessTokenRefreshError, getOAuthAccessTokenRefreshErrorDisposition, isRetryableOAuthUserInfoError, resolveOAuthAccessTokenExpiredAt } from "./base";
import { getOAuthAccessTokenRefreshError, getOAuthAccessTokenRefreshErrorDisposition, getOAuthCallbackExtras, isRetryableOAuthUserInfoError, resolveOAuthAccessTokenExpiredAt } from "./base";

describe("isRetryableOAuthUserInfoError", () => {
it("returns true for openid-client timeout errors", () => {
Expand Down Expand Up @@ -102,6 +102,27 @@ describe("getOAuthAccessTokenRefreshError", () => {
});
});

describe("getOAuthCallbackExtras", () => {
it("does not add token exchange scope by default", () => {
expect(getOAuthCallbackExtras({
baseScope: "User.Read openid",
extraScope: "Mail.Read",
})).toBeUndefined();
});

it("adds the merged scope to callback token exchange extras when enabled", () => {
expect(getOAuthCallbackExtras({
baseScope: "User.Read openid",
extraScope: "Mail.Read User.Read",
includeScopeInCallbackTokenRequest: true,
})).toEqual({
exchangeBody: {
scope: "User.Read openid Mail.Read",
},
});
});
});
Comment on lines +113 to +124
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing test for the common case where a Microsoft user has no extra scopes. When includeScopeInCallbackTokenRequest is true but extraScope is absent, the function should still return exchangeBody.scope containing just the base scope. Without this test, a future refactor of the extraScope || "" fallback could silently drop the scope from the token exchange body in the default (no extra scopes) flow.

Suggested change
it("adds the merged scope to callback token exchange extras when enabled", () => {
expect(getOAuthCallbackExtras({
baseScope: "User.Read openid",
extraScope: "Mail.Read User.Read",
includeScopeInCallbackTokenRequest: true,
})).toEqual({
exchangeBody: {
scope: "User.Read openid Mail.Read",
},
});
});
});
it("adds the merged scope to callback token exchange extras when enabled", () => {
expect(getOAuthCallbackExtras({
baseScope: "User.Read openid",
extraScope: "Mail.Read User.Read",
includeScopeInCallbackTokenRequest: true,
})).toEqual({
exchangeBody: {
scope: "User.Read openid Mail.Read",
},
});
});
it("uses only baseScope when no extraScope is provided and includeScopeInCallbackTokenRequest is true", () => {
expect(getOAuthCallbackExtras({
baseScope: "User.Read openid",
includeScopeInCallbackTokenRequest: true,
})).toEqual({
exchangeBody: {
scope: "User.Read openid",
},
});
});
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/oauth/providers/base.test.ts
Line: 113-124

Comment:
Missing test for the common case where a Microsoft user has no extra scopes. When `includeScopeInCallbackTokenRequest` is `true` but `extraScope` is absent, the function should still return `exchangeBody.scope` containing just the base scope. Without this test, a future refactor of the `extraScope || ""` fallback could silently drop the scope from the token exchange body in the default (no extra scopes) flow.

```suggestion
  it("adds the merged scope to callback token exchange extras when enabled", () => {
    expect(getOAuthCallbackExtras({
      baseScope: "User.Read openid",
      extraScope: "Mail.Read User.Read",
      includeScopeInCallbackTokenRequest: true,
    })).toEqual({
      exchangeBody: {
        scope: "User.Read openid Mail.Read",
      },
    });
  });

  it("uses only baseScope when no extraScope is provided and includeScopeInCallbackTokenRequest is true", () => {
    expect(getOAuthCallbackExtras({
      baseScope: "User.Read openid",
      includeScopeInCallbackTokenRequest: true,
    })).toEqual({
      exchangeBody: {
        scope: "User.Read openid",
      },
    });
  });
});
```

How can I resolve this? If you propose a fix, please make it concise.


describe("resolveOAuthAccessTokenExpiredAt", () => {
it("uses finite provider expires_in values", () => {
expect(resolveOAuthAccessTokenExpiredAt({
Expand Down
28 changes: 27 additions & 1 deletion apps/backend/src/oauth/providers/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/s
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
import { CallbackParamsType, Client, Issuer, TokenSet as OIDCTokenSet, custom, generators } from "openid-client";
import type { CallbackExtras, CallbackParamsType, Client, TokenSet as OIDCTokenSet } from "openid-client";
import { Issuer, custom, generators } from "openid-client";
import { OAuthUserInfo } from "../utils";

const OAUTH_USERINFO_TOTAL_ATTEMPTS = 3;
Expand Down Expand Up @@ -289,6 +290,22 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
};
}

export function getOAuthCallbackExtras(options: {
baseScope: string,
extraScope?: string,
includeScopeInCallbackTokenRequest?: boolean,
}): CallbackExtras | undefined {
if (options.includeScopeInCallbackTokenRequest !== true) {
return undefined;
}

return {
exchangeBody: {
scope: mergeScopeStrings(options.baseScope, options.extraScope || ""),
},
};
}

export abstract class OAuthBaseProvider {
constructor(
public readonly oauthClient: Client,
Expand All @@ -299,6 +316,7 @@ export abstract class OAuthBaseProvider {
public readonly noPKCE?: boolean,
public readonly openid?: boolean,
public readonly alternativeIssuers?: string[],
public readonly includeScopeInCallbackTokenRequest?: boolean,
) {}

protected static async createConstructorArgs(options:
Expand All @@ -312,6 +330,7 @@ export abstract class OAuthBaseProvider {
tokenEndpointAuthMethod?: "client_secret_post" | "client_secret_basic",
noPKCE?: boolean,
alternativeIssuers?: string[],
includeScopeInCallbackTokenRequest?: boolean,
}
& (
| ({
Expand Down Expand Up @@ -360,6 +379,7 @@ export abstract class OAuthBaseProvider {
options.noPKCE,
options.openid,
options.alternativeIssuers,
options.includeScopeInCallbackTokenRequest,
] as const;
}

Expand All @@ -386,6 +406,7 @@ export abstract class OAuthBaseProvider {
callbackParams: CallbackParamsType,
codeVerifier: string,
state: string,
extraScope?: string,
}): Promise<{ userInfo: OAuthUserInfo, tokenSet: TokenSet }> {
let tokenSet;
const callbackParams = { ...options.callbackParams };
Expand All @@ -408,6 +429,11 @@ export abstract class OAuthBaseProvider {
code_verifier: this.noPKCE ? undefined : options.codeVerifier,
state: options.state,
},
getOAuthCallbackExtras({
baseScope: this.scope,
extraScope: options.extraScope,
includeScopeInCallbackTokenRequest: this.includeScopeInCallbackTokenRequest,
}),
] as const;

try {
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/oauth/providers/microsoft.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class MicrosoftProvider extends OAuthBaseProvider {
baseScope: "User.Read openid",
openid: true,
jwksUri: `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`,
includeScopeInCallbackTokenRequest: true,
...options,
}));
}
Expand Down
Loading