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) { function validateClientDetails(client, payload) {
// Clients must use a single specific redirect_uri, if (!payload.redirect_uri && !client.redirectUri) {
// but they're allowed to not provide one and have us fill it in automatically. throw OauthError.incorrectRedirect();
payload.redirect_uri = payload.redirect_uri || client.redirectUri; }
if (payload.redirect_uri !== client.redirectUri) {
log.debug('redirect.mismatch', { // Starting in train-248, FxA added the ability for an OAuth client to support
param: payload.redirect_uri, // multiple redirect uris (comma separated list). The authorization flow redirect uri
registered: client.redirectUri, // 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 ( if (
config.oauthServer.localRedirects && config.oauthServer.localRedirects &&
isLocalHost(payload.redirect_uri) isLocalHost(payload.redirect_uri)
@ -142,6 +154,11 @@ module.exports = ({ log, oauthDB, config }) => {
} else { } else {
throw OauthError.incorrectRedirect(payload.redirect_uri); 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); 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. * Check whether `newsletters` contains only valid newsletter slugs.
* *

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

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

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

@ -21,7 +21,9 @@ const CLIENT_INFO_SCHEMA = {
id: Vat.hex().required().renameTo('clientId'), id: Vat.hex().required().renameTo('clientId'),
image_uri: Vat.url().allow('').renameTo('imageUri'), image_uri: Vat.url().allow('').renameTo('imageUri'),
name: Vat.string().required().min(1).renameTo('serviceName'), 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(), trusted: Vat.boolean().required(),
}; };
@ -246,8 +248,8 @@ var OAuthRelier = Relier.extend({
* *
* Verification (email) flows do not have a redirect uri, nothing to validate * Verification (email) flows do not have a redirect uri, nothing to validate
*/ */
if (!isCorrectRedirect(this.get('redirectUri'), result.redirectUri)) { if (!isCorrectRedirect(this.get('redirectUri'), result)) {
// if provided redirect uri doesn't match with client info then throw // if provided redirect uri doesn't match with any client redirectUri then throw
throw OAuthErrors.toError('INCORRECT_REDIRECT'); 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) { if (!queryRedirectUri) {
client.redirectUri = redirectUris[0];
return true; return true;
} else if (queryRedirectUri === resultRedirectUri) { }
const hasRedirectUri = redirectUris.some((uri) => {
if (queryRedirectUri === uri) {
return true; return true;
} else if ( }
});
if (hasRedirectUri) {
client.redirectUri = queryRedirectUri;
return true;
}
// Pairing has a special redirectUri that deep links into the specific
// mobile app
if (
queryRedirectUri === Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI queryRedirectUri === Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI
) { ) {
return true; return true;

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

@ -297,4 +297,26 @@ describe('lib/validate', function () {
assert.isTrue(Validate.isUtmValid('marketing-snippet')); 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_EXTRAS = 'profile:email profile:uid profile:non_whitelisted';
var SCOPE_WITH_OPENID = 'profile:email profile:uid openid'; var SCOPE_WITH_OPENID = 'profile:email profile:uid openid';
var SERVER_REDIRECT_URI = 'http://localhost:8080/api/oauth'; 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 = 'service';
var SERVICE_NAME = '123Done'; var SERVICE_NAME = '123Done';
var STATE = 'fakestatetoken'; var STATE = 'fakestatetoken';
@ -181,6 +183,22 @@ describe('models/reliers/oauth', () => {
assert.equal(err.param, 'code_challenge_method'); 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', () => { describe('verification flow', () => {
@ -403,7 +421,12 @@ describe('models/reliers/oauth', () => {
const invalidValues = ['', ' ', 'invalid']; const invalidValues = ['', ' ', 'invalid'];
testInvalidQueryParams('prompt', invalidValues); testInvalidQueryParams('prompt', invalidValues);
const validValues = [undefined, OAuthPrompt.CONSENT, OAuthPrompt.NONE]; const validValues = [
undefined,
OAuthPrompt.CONSENT,
OAuthPrompt.NONE,
OAuthPrompt.LOGIN,
];
testValidQueryParams('prompt', validValues, 'prompt', validValues); testValidQueryParams('prompt', validValues, 'prompt', validValues);
}); });
@ -575,7 +598,7 @@ describe('models/reliers/oauth', () => {
testMissingClientInfoValue('redirect_uri'); testMissingClientInfoValue('redirect_uri');
}); });
var invalidClientInfoValues = ['', ' ']; var invalidClientInfoValues = ['', ' ', ',', 'http://moz.org,'];
testInvalidClientInfoValues('redirect_uri', invalidClientInfoValues); testInvalidClientInfoValues('redirect_uri', invalidClientInfoValues);
}); });
@ -1242,7 +1265,7 @@ describe('models/reliers/oauth', () => {
var clientInfo = { var clientInfo = {
id: CLIENT_ID, id: CLIENT_ID,
name: SERVICE_NAME, name: SERVICE_NAME,
redirect_uri: SERVER_REDIRECT_URI, redirect_uri: SERVER_REDIRECT_URIS,
trusted: isTrusted, trusted: isTrusted,
}; };
@ -1260,7 +1283,6 @@ describe('models/reliers/oauth', () => {
function fetchExpectError(params) { function fetchExpectError(params) {
windowMock.location.search = toSearchString(params); windowMock.location.search = toSearchString(params);
return relier.fetch().then(assert.fail, function (_err) { return relier.fetch().then(assert.fail, function (_err) {
err = _err; err = _err;
}); });