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

Fix "Mismatching redirect URL" #3103

Merged
merged 3 commits into from
Aug 28, 2023
Merged
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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ The following have been deprecated, and will be removed in future major releases

The following changes have been implemented but not released yet:

### Bugfixes

#### browser

- [Mismatching redirect URI](https://github.com/inrupt/solid-client-authn-js/issues/2891) on refresh: this bug was caused by an invalid redirect URL stored with session data.
Saving an invalid redirect URL is now prohibited, and in addition the storage of users impacted by this bug will be cleared so that they don't have to do anything manually
to clear their local storage. Users affected by this bug will be asked to log back in, as if they logged out.

## [1.17.1](https://github.com/inrupt/solid-client-authn-js/releases/tag/v1.17.1) - 2023-07-15

#### Bugfixes
### Bugfixes

- The `fetch` function is now bound to the window object in all uses within `authn-browser`

Expand Down
28 changes: 28 additions & 0 deletions packages/browser/src/ClientAuthentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,34 @@ describe("ClientAuthentication", () => {
).rejects.toThrow("hash fragment");
});

it("throws if the redirect IRI contains a reserved query parameter, with a helpful message", async () => {
const clientAuthn = getClientAuthentication();
await expect(() =>
clientAuthn.login(
{
sessionId: "someUser",
tokenType: "DPoP",
clientId: "coolApp",
redirectUrl: "https://example.org/redirect?state=1234",
oidcIssuer: "https://idp.com",
},
mockEmitter,
),
).rejects.toThrow("query parameter");
await expect(() =>
clientAuthn.login(
{
sessionId: "someUser",
tokenType: "DPoP",
clientId: "coolApp",
redirectUrl: "https://example.org/redirect?code=1234",
oidcIssuer: "https://idp.com",
},
mockEmitter,
),
).rejects.toThrow("query parameter");
});

it("does not normalize the redirect URL if provided by the user", async () => {
const clientAuthn = getClientAuthentication();
await clientAuthn.login(
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/src/ClientAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
options.redirectUrl ?? removeOidcQueryParam(window.location.href);
if (!isValidRedirectUrl(redirectUrl)) {
throw new Error(
`${redirectUrl} is not a valid redirect URL, it is either a malformed IRI or it includes a hash fragment.`,
`${redirectUrl} is not a valid redirect URL, it is either a malformed IRI, includes a hash fragment, or reserved query parameters ('code' or 'state').`,
);
}
await this.loginHandler.handle({
Expand Down
22 changes: 22 additions & 0 deletions packages/browser/src/sessionInfo/SessionInfoManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,28 @@ describe("SessionInfoManager", () => {
});
});

it("returns undefined and clears storage if the redirect URL is invalid", async () => {
const sessionId = "commanderCool";
const storageMock = new StorageUtility(
mockStorage({}),
mockStorage({
[`solidClientAuthenticationUser:${sessionId}`]: {
// The state query parameter is reserved in OpenID.
redirectUrl: "https://client.example.org/callback?state=1234",
},
}),
);
const spiedClear = jest.spyOn(storageMock, "deleteAllUserData");
const sessionManager = getSessionInfoManager({
storageUtility: storageMock,
});

const session = await sessionManager.get(sessionId);
expect(session).toBeUndefined();
expect(spiedClear).toHaveBeenCalledWith(sessionId, { secure: false });
expect(spiedClear).toHaveBeenCalledWith(sessionId, { secure: true });
});

it("returns undefined if the specified storage does not contain the user", async () => {
const sessionManager = getSessionInfoManager({
storageUtility: mockStorageUtility({}, true),
Expand Down
92 changes: 44 additions & 48 deletions packages/browser/src/sessionInfo/SessionInfoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
isSupportedTokenType,
clear as clearBase,
SessionInfoManagerBase,
isValidRedirectUrl,
} from "@inrupt/solid-client-authn-core";
import { clearOidcPersistentStorage } from "@inrupt/oidc-client-ext";

Expand Down Expand Up @@ -62,60 +63,54 @@ export class SessionInfoManager
async get(
sessionId: string,
): Promise<(ISessionInfo & ISessionInternalInfo) | undefined> {
const isLoggedIn = await this.storageUtility.getForUser(
sessionId,
"isLoggedIn",
{
const [
isLoggedIn,
webId,
clientId,
clientSecret,
redirectUrl,
refreshToken,
issuer,
tokenType,
] = await Promise.all([
this.storageUtility.getForUser(sessionId, "isLoggedIn", {
secure: true,
},
);

const webId = await this.storageUtility.getForUser(sessionId, "webId", {
secure: true,
});

const clientId = await this.storageUtility.getForUser(
sessionId,
"clientId",
{
}),
this.storageUtility.getForUser(sessionId, "webId", {
secure: true,
}),
this.storageUtility.getForUser(sessionId, "clientId", {
secure: false,
},
);

const clientSecret = await this.storageUtility.getForUser(
sessionId,
"clientSecret",
{
}),
this.storageUtility.getForUser(sessionId, "clientSecret", {
secure: false,
},
);

const redirectUrl = await this.storageUtility.getForUser(
sessionId,
"redirectUrl",
{
}),
this.storageUtility.getForUser(sessionId, "redirectUrl", {
secure: false,
},
);

const refreshToken = await this.storageUtility.getForUser(
sessionId,
"refreshToken",
{
}),
this.storageUtility.getForUser(sessionId, "refreshToken", {
secure: true,
},
);

const issuer = await this.storageUtility.getForUser(sessionId, "issuer", {
secure: false,
});

const tokenType =
(await this.storageUtility.getForUser(sessionId, "tokenType", {
}),
this.storageUtility.getForUser(sessionId, "issuer", {
secure: false,
}),
this.storageUtility.getForUser(sessionId, "tokenType", {
secure: false,
})) ?? "DPoP";
}),
]);

if (typeof redirectUrl === "string" && !isValidRedirectUrl(redirectUrl)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Are there cases where this could be valid but still not the correct redirect url?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Potentially, but it would become harder for the library to pick that up. One issue is that the failure happens out of the domain of the client, so the client is not informed that the OpenID Provider errors on their redirect_url, and there is no way for the client to look up what redirect URL has been registered by the client at dynamic registration.

I'm not even sure how the bad redirect URL gets in storage in the first place, but I suspect that using window.location.href plays a role. In that case, a likely cause of failure is picking up the OpenID query params (which is the observed cause of the bug in the reproduced issues).

If what you describe is indeed an issue, we'll need to figure out how to prevent it from happening, but for the time being I think it is low probabllity enough that we don't work on a fix for the time being.

// This resolves the issue for people experiencing https://github.com/inrupt/solid-client-authn-js/issues/2891.
// An invalid redirect URL is present in the storage, and the session should
// be cleared to get a fresh start. This will require the user to log back in.
await Promise.all([
this.storageUtility.deleteAllUserData(sessionId, { secure: false }),
this.storageUtility.deleteAllUserData(sessionId, { secure: true }),
]);
return undefined;
}

if (!isSupportedTokenType(tokenType)) {
if (tokenType !== undefined && !isSupportedTokenType(tokenType)) {
throw new Error(`Tokens of type [${tokenType}] are not supported.`);
}

Expand All @@ -137,7 +132,8 @@ export class SessionInfoManager
issuer,
clientAppId: clientId,
clientAppSecret: clientSecret,
tokenType,
// Default the token type to DPoP if unspecified.
tokenType: tokenType ?? "DPoP",
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/login/oidc/validateRedirectIri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ export function isValidRedirectUrl(redirectUrl: string): boolean {
// If the redirect URL is not a valid URL, an error will be thrown.
try {
const urlObject = new URL(redirectUrl);
const noReservedQuery =
!urlObject.searchParams.has("code") &&
!urlObject.searchParams.has("state");
// As per https://tools.ietf.org/html/rfc6749#section-3.1.2, the redirect URL
// must not include a hash fragment.
return urlObject.hash === "";
const noHash = urlObject.hash === "";
return noReservedQuery && noHash;
} catch (e) {
return false;
}
Expand Down
28 changes: 28 additions & 0 deletions packages/node/src/ClientAuthentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,34 @@ describe("ClientAuthentication", () => {
).rejects.toThrow("hash fragment");
});

it("throws if the redirect IRI contains a reserved query parameter, with a helpful message", async () => {
const clientAuthn = getClientAuthentication();
await expect(() =>
clientAuthn.login(
"mySession",
{
tokenType: "DPoP",
clientId: "coolApp",
redirectUrl: "https://example.org/redirect?state=1234",
oidcIssuer: "https://idp.com",
},
mockEmitter,
),
).rejects.toThrow("query parameter");
await expect(() =>
clientAuthn.login(
"mySession",
{
tokenType: "DPoP",
clientId: "coolApp",
redirectUrl: "https://example.org/redirect?code=1234",
oidcIssuer: "https://idp.com",
},
mockEmitter,
),
).rejects.toThrow("query parameter");
});

it("does not normalize the redirect URL if provided by the user", async () => {
const clientAuthn = getClientAuthentication();
await clientAuthn.login(
Expand Down
2 changes: 1 addition & 1 deletion packages/node/src/ClientAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
!isValidRedirectUrl(options.redirectUrl)
) {
throw new Error(
`${options.redirectUrl} is not a valid redirect URL, it is either a malformed IRI or it includes a hash fragment.`,
`${options.redirectUrl} is not a valid redirect URL, it is either a malformed IRI, includes a hash fragment, or reserved query parameters ('code' or 'state').`,
);
}
const loginReturn = await this.loginHandler.handle({
Expand Down