feat(oauth): Support multiple client redirect urls

This commit is contained in:
Vijay Budhram 2022-12-14 14:24:32 -05:00
Родитель c4ed0ef693
Коммит 5062274527
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: EBAEC5D86596C9EE
9 изменённых файлов: 125 добавлений и 19 удалений

Просмотреть файл

@ -0,0 +1,5 @@
-- To support multiple redirect uris, we are changing the column form varchar 256 to 2048
-- should enough to store 20 redirect uris.
ALTER TABLE `clients` CHANGE COLUMN `redirectUri` `redirectUri` VARCHAR(2048) NULL DEFAULT NULL;
UPDATE dbMetadata SET value = '32' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,3 @@
-- ALTER TABLE `clients` CHANGE COLUMN `redirectUri` `redirectUri` VARCHAR(256) NULL DEFAULT NULL;
-- UPDATE dbMetadata SET value = '31' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -1,3 +1,3 @@
{
"level": 31
"level": 32
}

Просмотреть файл

@ -126,14 +126,26 @@ module.exports = ({ log, oauthDB, config }) => {
}
function validateClientDetails(client, payload) {
// Clients must use a single specific redirect_uri,
// but they're allowed to not provide one and have us fill it in automatically.
payload.redirect_uri = payload.redirect_uri || client.redirectUri;
if (payload.redirect_uri !== client.redirectUri) {
log.debug('redirect.mismatch', {
param: payload.redirect_uri,
registered: client.redirectUri,
});
if (!payload.redirect_uri && !client.redirectUri) {
throw OauthError.incorrectRedirect();
}
// Starting in train-248, FxA added the ability for an OAuth client to support
// multiple redirect uris (comma separated list). The authorization flow redirect uri
// must match one of these exactly. Pattern matching is not supported.
const redirectUris = client.redirectUri.split(',');
// Authorization flow must use a single specific redirect_uri,
// but allowed to not provide one and have us fill it in automatically.
payload.redirect_uri = payload.redirect_uri || redirectUris[0];
const validUri = redirectUris.some((uri) => {
if (uri === payload.redirect_uri) {
return true;
}
});
if (!validUri) {
if (
config.oauthServer.localRedirects &&
isLocalHost(payload.redirect_uri)
@ -142,6 +154,11 @@ module.exports = ({ log, oauthDB, config }) => {
} else {
throw OauthError.incorrectRedirect(payload.redirect_uri);
}
} else {
log.debug('redirect.mismatch', {
param: payload.redirect_uri,
registered: client.redirectUri,
});
}
}

Просмотреть файл

@ -234,6 +234,21 @@ var Validate = {
return JWT_STRING.test(value);
},
/**
* Check whether `redirectUri` string contains only valid uris.
* Clients can specify a comma separated list and this checks to see
* if each is a valid uri.
*
* @param {String} redirectUrisStr
* @returns {Boolean}
*/
isRedirectUriValid(redirectUrisStr) {
const redirectUris = redirectUrisStr.split(',');
return redirectUris.every((value) => {
return urlRegEx.test(value);
});
},
/**
* Check whether `newsletters` contains only valid newsletter slugs.
*

Просмотреть файл

@ -44,6 +44,10 @@ Vat.register(
'verificationRedirect',
Vat.string().test(Validate.isVerificationRedirectValid)
);
Vat.register(
'redirectUri',
Vat.string().required().test(Validate.isRedirectUriValid)
);
// depends on hex, must come afterwards
Vat.register('clientId', Vat.hex());

Просмотреть файл

@ -21,7 +21,9 @@ const CLIENT_INFO_SCHEMA = {
id: Vat.hex().required().renameTo('clientId'),
image_uri: Vat.url().allow('').renameTo('imageUri'),
name: Vat.string().required().min(1).renameTo('serviceName'),
redirect_uri: Vat.url().required().renameTo('redirectUri'),
// This can be a single uri or comma separated list
redirect_uri: Vat.redirectUri().renameTo('redirectUri'),
trusted: Vat.boolean().required(),
};
@ -246,8 +248,8 @@ var OAuthRelier = Relier.extend({
*
* Verification (email) flows do not have a redirect uri, nothing to validate
*/
if (!isCorrectRedirect(this.get('redirectUri'), result.redirectUri)) {
// if provided redirect uri doesn't match with client info then throw
if (!isCorrectRedirect(this.get('redirectUri'), result)) {
// if provided redirect uri doesn't match with any client redirectUri then throw
throw OAuthErrors.toError('INCORRECT_REDIRECT');
}
@ -270,12 +272,28 @@ var OAuthRelier = Relier.extend({
}
);
function isCorrectRedirect(queryRedirectUri, resultRedirectUri) {
function isCorrectRedirect(queryRedirectUri, client) {
// If RP doesn't specify redirectUri, we default to the first redirectUri
// for the client
const redirectUris = client.redirectUri.split(',');
if (!queryRedirectUri) {
client.redirectUri = redirectUris[0];
return true;
} else if (queryRedirectUri === resultRedirectUri) {
}
const hasRedirectUri = redirectUris.some((uri) => {
if (queryRedirectUri === uri) {
return true;
}
});
if (hasRedirectUri) {
client.redirectUri = queryRedirectUri;
return true;
} else if (
}
// Pairing has a special redirectUri that deep links into the specific
// mobile app
if (
queryRedirectUri === Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI
) {
return true;

Просмотреть файл

@ -297,4 +297,26 @@ describe('lib/validate', function () {
assert.isTrue(Validate.isUtmValid('marketing-snippet'));
});
});
describe('isRedirectUriValid', () => {
it('returns false if any redirect uri is not valid', () => {
const testString = 'https://localhost,http://';
assert.isFalse(Validate.isRedirectUriValid(testString));
});
it('returns false for invalid uri', () => {
const testString = 'c';
assert.isFalse(Validate.isRedirectUriValid(testString));
});
it('returns true for valid uris', () => {
const testString = 'https://localhost,http://mozilla.org';
assert.isTrue(Validate.isRedirectUriValid(testString));
});
it('returns true for single valid uri', () => {
const testString = 'https://localhost';
assert.isTrue(Validate.isRedirectUriValid(testString));
});
});
});

Просмотреть файл

@ -44,6 +44,8 @@ describe('models/reliers/oauth', () => {
var SCOPE_WITH_EXTRAS = 'profile:email profile:uid profile:non_whitelisted';
var SCOPE_WITH_OPENID = 'profile:email profile:uid openid';
var SERVER_REDIRECT_URI = 'http://localhost:8080/api/oauth';
var SERVER_REDIRECT_URIS =
'http://localhost:8080/api/oauth,https://www.mozilla.org';
var SERVICE = 'service';
var SERVICE_NAME = '123Done';
var STATE = 'fakestatetoken';
@ -181,6 +183,22 @@ describe('models/reliers/oauth', () => {
assert.equal(err.param, 'code_challenge_method');
});
});
it('throws if incorrect `redirectUri` is specified', () => {
windowMock.location.search = toSearchString({
access_type: ACCESS_TYPE,
action: ACTION,
client_id: CLIENT_ID,
prompt: PROMPT,
redirect_uri: 'http://www.notvalidclienturl.com',
scope: SCOPE,
state: STATE,
});
return relier.fetch().then(assert.fail, (err) => {
assert.isTrue(OAuthErrors.is(err, 'INCORRECT_REDIRECT'));
});
});
});
describe('verification flow', () => {
@ -403,7 +421,12 @@ describe('models/reliers/oauth', () => {
const invalidValues = ['', ' ', 'invalid'];
testInvalidQueryParams('prompt', invalidValues);
const validValues = [undefined, OAuthPrompt.CONSENT, OAuthPrompt.NONE];
const validValues = [
undefined,
OAuthPrompt.CONSENT,
OAuthPrompt.NONE,
OAuthPrompt.LOGIN,
];
testValidQueryParams('prompt', validValues, 'prompt', validValues);
});
@ -575,7 +598,7 @@ describe('models/reliers/oauth', () => {
testMissingClientInfoValue('redirect_uri');
});
var invalidClientInfoValues = ['', ' '];
var invalidClientInfoValues = ['', ' ', ',', 'http://moz.org,'];
testInvalidClientInfoValues('redirect_uri', invalidClientInfoValues);
});
@ -1242,7 +1265,7 @@ describe('models/reliers/oauth', () => {
var clientInfo = {
id: CLIENT_ID,
name: SERVICE_NAME,
redirect_uri: SERVER_REDIRECT_URI,
redirect_uri: SERVER_REDIRECT_URIS,
trusted: isTrusted,
};
@ -1260,7 +1283,6 @@ describe('models/reliers/oauth', () => {
function fetchExpectError(params) {
windowMock.location.search = toSearchString(params);
return relier.fetch().then(assert.fail, function (_err) {
err = _err;
});