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

enable using default scopes from authorization server #1405

Merged
merged 11 commits into from
Jun 24, 2024
Merged
11 changes: 8 additions & 3 deletions docs/oidc-client-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export class OidcClient {
// (undocumented)
clearStaleState(): Promise<void>;
// (undocumented)
createSigninRequest({ state, request, request_uri, request_type, id_token_hint, login_hint, skipUserInfo, nonce, url_state, response_type, scope, redirect_uri, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, extraQueryParams, extraTokenParams, }: CreateSigninRequestArgs): Promise<SigninRequest>;
createSigninRequest({ state, request, request_uri, request_type, id_token_hint, login_hint, skipUserInfo, nonce, url_state, response_type, scope, redirect_uri, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, extraQueryParams, extraTokenParams, omitScopeWhenRequesting, }: CreateSigninRequestArgs): Promise<SigninRequest>;
// (undocumented)
createSignoutRequest({ state, id_token_hint, client_id, request_type, post_logout_redirect_uri, extraQueryParams, }?: CreateSignoutRequestArgs): Promise<SignoutRequest>;
// (undocumented)
Expand Down Expand Up @@ -365,6 +365,7 @@ export interface OidcClientSettings {
metadataSeed?: Partial<OidcMetadata>;
// (undocumented)
metadataUrl?: string;
omitScopeWhenRequesting?: boolean;
post_logout_redirect_uri?: string;
prompt?: string;
redirect_uri: string;
Expand All @@ -383,7 +384,7 @@ export interface OidcClientSettings {

// @public
export class OidcClientSettingsStore {
constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, }: OidcClientSettings);
constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, omitScopeWhenRequesting, }: OidcClientSettings);
// (undocumented)
readonly acr_values: string | undefined;
// (undocumented)
Expand Down Expand Up @@ -423,6 +424,8 @@ export class OidcClientSettingsStore {
// (undocumented)
readonly metadataUrl: string;
// (undocumented)
readonly omitScopeWhenRequesting: boolean;
// (undocumented)
readonly post_logout_redirect_uri: string | undefined;
// (undocumented)
readonly prompt: string | undefined;
Expand Down Expand Up @@ -626,7 +629,7 @@ export type SigninRedirectArgs = RedirectParams & ExtraSigninRequestArgs;
// @public (undocumented)
export class SigninRequest {
// (undocumented)
static create({ url, authority, client_id, redirect_uri, response_type, scope, state_data, response_mode, request_type, client_secret, nonce, url_state, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, ...optionalParams }: SigninRequestCreateArgs): Promise<SigninRequest>;
static create({ url, authority, client_id, redirect_uri, response_type, scope, state_data, response_mode, request_type, client_secret, nonce, url_state, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, omitScopeWhenRequesting, ...optionalParams }: SigninRequestCreateArgs): Promise<SigninRequest>;
// (undocumented)
readonly state: SigninState;
// (undocumented)
Expand Down Expand Up @@ -660,6 +663,8 @@ export interface SigninRequestCreateArgs {
// (undocumented)
nonce?: string;
// (undocumented)
omitScopeWhenRequesting?: boolean;
// (undocumented)
prompt?: string;
// (undocumented)
redirect_uri: string;
Expand Down
28 changes: 28 additions & 0 deletions src/OidcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,34 @@ describe("OidcClient", () => {
expect(url.match(/state=.*%3Burl_state/)).toBeTruthy();
});

it("should exclude scope from SigninRequest if omitScopeWhenRequesting is true", async () => {
// arrange
jest.spyOn(subject.metadataService, "getAuthorizationEndpoint").mockImplementation(() => Promise.resolve("http://sts/authorize"));

// act
const request = await subject.createSigninRequest({
state: "foo",
response_type: "code",
scope: "baz",
redirect_uri: "quux",
prompt: "p",
display: "d",
max_age: 42,
ui_locales: "u",
id_token_hint: "ith",
login_hint: "lh",
acr_values: "av",
resource: "res",
url_state: "url_state",
omitScopeWhenRequesting: true,
});

// assert
expect(request.state.data).toEqual("foo");
const url = request.url;
expect(url).not.toContain("scope");
});

it("should fail if implicit flow requested", async () => {
// arrange
const args = {
Expand Down
2 changes: 2 additions & 0 deletions src/OidcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class OidcClient {
response_mode = this.settings.response_mode,
extraQueryParams = this.settings.extraQueryParams,
extraTokenParams = this.settings.extraTokenParams,
omitScopeWhenRequesting = this.settings.omitScopeWhenRequesting,
}: CreateSigninRequestArgs): Promise<SigninRequest> {
const logger = this._logger.create("createSigninRequest");

Expand All @@ -136,6 +137,7 @@ export class OidcClient {
skipUserInfo,
nonce,
disablePKCE: this.settings.disablePKCE,
omitScopeWhenRequesting,
});

// house cleaning
Expand Down
28 changes: 28 additions & 0 deletions src/OidcClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,4 +530,32 @@ describe("OidcClientSettings", () => {
expect(subject.extraHeaders).toEqual(extraHeaders);
});
});

describe("omitScopeWhenRequesting", () => {

it("should use default value", () => {
// act
const subject = new OidcClientSettingsStore({
authority: "authority",
client_id: "client",
redirect_uri: "redirect",
});

// assert
expect(subject.omitScopeWhenRequesting).toEqual(false);
});

it("should return value from initial settings", () => {
// act
const subject = new OidcClientSettingsStore({
authority: "authority",
client_id: "client",
redirect_uri: "redirect",
omitScopeWhenRequesting: true,
});

// assert
expect(subject.omitScopeWhenRequesting).toEqual(true);
});
});
});
9 changes: 9 additions & 0 deletions src/OidcClientSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ export interface OidcClientSettings {
* Defines request timeouts globally across all requests made to the authorisation server
*/
requestTimeoutInSeconds?: number | undefined;

/**
* https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 describes behavior when omitting scopes from sign in requests
* If the IDP supports default scopes, this setting will ignore the scopes property passed to the config. (Default: false)
*/
omitScopeWhenRequesting?: boolean;
}

/**
Expand Down Expand Up @@ -181,6 +187,7 @@ export class OidcClientSettingsStore {
public readonly loadUserInfo: boolean;
public readonly staleStateAgeInSeconds: number;
public readonly mergeClaimsStrategy: { array: "replace" | "merge" };
public readonly omitScopeWhenRequesting: boolean;

public readonly stateStore: StateStore;

Expand Down Expand Up @@ -220,6 +227,7 @@ export class OidcClientSettingsStore {
extraQueryParams = {},
extraTokenParams = {},
extraHeaders = {},
omitScopeWhenRequesting = false,
}: OidcClientSettings) {

this.authority = authority;
Expand Down Expand Up @@ -260,6 +268,7 @@ export class OidcClientSettingsStore {
this.loadUserInfo = !!loadUserInfo;
this.staleStateAgeInSeconds = staleStateAgeInSeconds;
this.mergeClaimsStrategy = mergeClaimsStrategy;
this.omitScopeWhenRequesting = omitScopeWhenRequesting;
this.disablePKCE = !!disablePKCE;
this.revokeTokenAdditionalContentTypes = revokeTokenAdditionalContentTypes;

Expand Down
12 changes: 12 additions & 0 deletions src/SigninRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ describe("SigninRequest", () => {
expect(url).toContain("scope=openid");
});

it("should not include scope if omitScopeWhenRequesting is true", async () => {
// arrange
settings.omitScopeWhenRequesting = true;

// act
subject = await SigninRequest.create(settings);
url = subject.url;

// assert
expect(url).not.toContain("scope");
});

it("should include state", () => {
// assert
expect(url).toContain("state=" + subject.state.id);
Expand Down
6 changes: 5 additions & 1 deletion src/SigninRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface SigninRequestCreateArgs {
/** custom "state", which can be used by a caller to have "data" round tripped */
state_data?: unknown;
url_state?: string;
omitScopeWhenRequesting?: boolean;
}

/**
Expand Down Expand Up @@ -72,6 +73,7 @@ export class SigninRequest {
extraQueryParams,
extraTokenParams,
disablePKCE,
omitScopeWhenRequesting,
...optionalParams
}: SigninRequestCreateArgs): Promise<SigninRequest> {
if (!url) {
Expand Down Expand Up @@ -114,7 +116,9 @@ export class SigninRequest {
parsedUrl.searchParams.append("client_id", client_id);
parsedUrl.searchParams.append("redirect_uri", redirect_uri);
parsedUrl.searchParams.append("response_type", response_type);
parsedUrl.searchParams.append("scope", scope);
if (!omitScopeWhenRequesting) {
parsedUrl.searchParams.append("scope", scope);
}
if (nonce) {
parsedUrl.searchParams.append("nonce", nonce);
}
Expand Down
26 changes: 26 additions & 0 deletions src/TokenClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,32 @@ describe("TokenClient", () => {
expect(params).toHaveProperty("client_secret", "client_secret");
});

it("should not include scope if omitScopeWhenRequesting is true", async () => {
settings.omitScopeWhenRequesting = true;
subject = new TokenClient(new OidcClientSettingsStore(settings), metadataService);
const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
.mockResolvedValue("http://sts/token_endpoint");
const postFormMock = jest.spyOn(subject["_jsonService"], "postForm")
.mockResolvedValue({});

// act
await subject.exchangeRefreshToken({ refresh_token: "refresh_token" });

// assert
expect(getTokenEndpointMock).toHaveBeenCalledWith(false);
expect(postFormMock).toHaveBeenCalledWith(
"http://sts/token_endpoint",
expect.objectContaining({
body: expect.any(URLSearchParams),
basicAuth: undefined,
timeoutInSeconds: undefined,
}),
);
const opts = postFormMock.mock.calls[0][1];
const params = Object.fromEntries(opts.body);
expect(params).not.toHaveProperty("scope");
});

it("should call postForm", async () => {
// arrange
const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
Expand Down
5 changes: 4 additions & 1 deletion src/TokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@ export class TokenClient {
logger.throw(new Error("A client_id is required"));
}

const params = new URLSearchParams({ grant_type, scope });
const params = new URLSearchParams({ grant_type });
if (!this._settings.omitScopeWhenRequesting) {
params.set("scope", scope);
}
for (const [key, value] of Object.entries(args)) {
if (value != null) {
params.set(key, value);
Expand Down