feat(oauth): Add /oauth/authorization route, authenticated with a sessionToken.

This commit is contained in:
Ryan Kelly 2019-02-26 12:24:45 +11:00
Родитель 9564168b28
Коммит c3bb754c57
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: FB70C973A037D258
13 изменённых файлов: 635 добавлений и 34 удалений

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

@ -47,6 +47,7 @@ see [`mozilla/fxa-js-client`](https://github.com/mozilla/fxa-js-client).
* [Oauth](#oauth) * [Oauth](#oauth)
* [GET /oauth/client/{client_id}](#get-oauthclientclient_id) * [GET /oauth/client/{client_id}](#get-oauthclientclient_id)
* [POST /account/scoped-key-data (:lock: sessionToken)](#post-accountscoped-key-data) * [POST /account/scoped-key-data (:lock: sessionToken)](#post-accountscoped-key-data)
* [POST /oauth/authorization (:lock: sessionToken)](#post-oauthauthorization)
* [Password](#password) * [Password](#password)
* [POST /password/change/start](#post-passwordchangestart) * [POST /password/change/start](#post-passwordchangestart)
* [POST /password/change/finish (:lock: passwordChangeToken)](#post-passwordchangefinish) * [POST /password/change/finish (:lock: passwordChangeToken)](#post-passwordchangefinish)
@ -303,6 +304,16 @@ for `code` and `errno` are:
Redis WATCH detected a conflicting update Redis WATCH detected a conflicting update
* `code: 400, errno: 166`: * `code: 400, errno: 166`:
Not a public client Not a public client
* `code: 400, errno: 167`:
Incorrect redirect URI
* `code: 400, errno: 168`:
Invalid response_type
* `code: 400, errno: 169`:
Requested scopes are not allowed
* `code: 400, errno: 170`:
Public clients require PKCE OAuth parameters
* `code: 400, errno: 171`:
Required Authentication Context Reference values could not be satisfied
* `code: 503, errno: 201`: * `code: 503, errno: 201`:
Service unavailable Service unavailable
* `code: 503, errno: 202`: * `code: 503, errno: 202`:
@ -337,6 +348,9 @@ include additional response properties:
* `errno: 153` * `errno: 153`
* `errno: 162`: clientId * `errno: 162`: clientId
* `errno: 164`: authAt * `errno: 164`: authAt
* `errno: 167`: redirectUri
* `errno: 169`: invalidScopes
* `errno: 171`: foundValue
* `errno: 201`: retryAfter * `errno: 201`: retryAfter
* `errno: 202`: retryAfter * `errno: 202`: retryAfter
* `errno: 203`: service, operation * `errno: 203`: service, operation
@ -380,8 +394,11 @@ those common validations are defined here.
* `clientId`: `module.exports.hexString.length(16)` * `clientId`: `module.exports.hexString.length(16)`
* `accessToken`: `module.exports.hexString.length(64)` * `accessToken`: `module.exports.hexString.length(64)`
* `refreshToken`: `module.exports.hexString.length(64)` * `refreshToken`: `module.exports.hexString.length(64)`
* `authorizationCode`: `module.exports.hexString.length(64)`
* `scope`: `string, max(256), regex(/^[a-zA-Z0-9 _\/.:-]+$/)` * `scope`: `string, max(256), regex(/^[a-zA-Z0-9 _\/.:-]+$/)`
* `assertion`: `string, min(50), max(10240), regex(/^[a-zA-Z0-9_\-\.~=]+$/)` * `assertion`: `string, min(50), max(10240), regex(/^[a-zA-Z0-9_\-\.~=]+$/)`
* `pkceCodeChallengeMethod`: `string, valid('S256')`
* `pkceCodeChallenge`: `string, length(43), regex(module, exports.URL_SAFE_BASE_64)`
* `jwe`: `string, max(1024), regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/)` * `jwe`: `string, max(1024), regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/)`
* `verificationMethod`: `string, valid()` * `verificationMethod`: `string, valid()`
* `authPW`: `string, length(64), regex(HEX_STRING), required` * `authPW`: `string, length(64), regex(HEX_STRING), required`
@ -1944,6 +1961,54 @@ requested by the specified OAuth client.
<!--end-route-post-accountscoped-key-data--> <!--end-route-post-accountscoped-key-data-->
#### POST /oauth/authorization
:lock: HAWK-authenticated with session token
<!--begin-route-post-oauthauthorization-->
Authorize a new OAuth client connection to the user's account,
returning a short-lived authentication code that the client can
exchange for access tokens at the OAuth token endpoint.
This route behaves like the (oauth-server /authorization endpoint)[../fxa-oauth-server/docs/api.md#post-v1authorization]
except that it is authenticated directly with a sessionToken
rather than with a BrowserID assertion.
##### Request body
* `client_id`: *validators.email.required*
The OAuth client identifier provided by the connecting client application.
* `state`: *string, max(256), required*
An opaque string provided by the connecting client application, which will be
returned unmodified alongside the authorization code. This can be used by
the connecting client to guard against certain classes of attack in the
redirect-based OAuth flow.
* `response_type`: *string, valid('code'), optional*
Determines the format of the response. Since we only support the authorization-code grant flow,
the only permitted value is 'code'.
* `redirect_uri`: *string, URI, optional*
The URI at which the connecting client expects to receive the authorization code.
If supplied this *must* match the value provided during OAuth client registration.
* `scope`: *string, optional*
A space-separated list of scope values that the connecting client will be granted.
The requested scope will be provided by the connecting client as part of its authorization request,
but may be pruned by the user in a confirmation dialog before being sent to this endpoint.
* `access_type`: *string, valid(online, offline), optional*
If specified, a value of `offline` will cause the connecting client to be granted a refresh token
alongside its access token.
* `code_challenge_method`: *string, valid(S256), optional*
Required for public OAuth clients, who must authenticate their authorization code use via [PKCE](../fxa-oauth-server/docs/pkce.md).
The only support method is 'S256'.
* `code_challenge`: *string, length(43), regex(URL_SAFE_BASE_64), optional*
Required for public OAuth clients, who must authenticate their authorization code use via [PKCE](../fxa-oauth-server/docs/pkce.md).
* `keys_jwe`: *string, validators.jwe, optional*
An encrypted bundle of key material, to be returned to the client when it redeems the authorization code.
* `acr_values`: *string, optional*
A space-separated list of ACR values specifying acceptable levels of user authentication.
Specifying `AAL2` will ensure that the user has been authenticated with 2FA before authorizing
the requested grant.
<!--end-route-post-oauthauthorization-->
### Password ### Password
#### POST /password/change/start #### POST /password/change/start

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

@ -72,9 +72,14 @@ const ERRNO = {
TOTP_REQUIRED: 160, TOTP_REQUIRED: 160,
RECOVERY_KEY_EXISTS: 161, RECOVERY_KEY_EXISTS: 161,
UNKNOWN_CLIENT_ID: 162, UNKNOWN_CLIENT_ID: 162,
INVALID_SCOPES: 163,
STALE_AUTH_AT: 164, STALE_AUTH_AT: 164,
REDIS_CONFLICT: 165, REDIS_CONFLICT: 165,
NOT_PUBLIC_CLIENT: 166, NOT_PUBLIC_CLIENT: 166,
INCORRECT_REDIRECT_URI: 167,
INVALID_RESPONSE_TYPE: 168,
MISSING_PKCE_PARAMETERS: 169,
INSUFFICIENT_ACR_VALUES: 170,
SERVER_BUSY: 201, SERVER_BUSY: 201,
FEATURE_NOT_ENABLED: 202, FEATURE_NOT_ENABLED: 202,
@ -926,6 +931,57 @@ AppError.redisConflict = () => {
}); });
}; };
AppError.incorrectRedirectURI = (redirectUri) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INCORRECT_REDIRECT_URI,
message: 'Incorrect redirect URI'
}, {
redirectUri
});
};
AppError.invalidResponseType = () => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_RESPONSE_TYPE,
message: 'Invalid response_type'
});
};
AppError.invalidScopes = (invalidScopes) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_SCOPES,
message: 'Requested scopes are not allowed'
}, {
invalidScopes
});
};
AppError.missingPkceParameters = () => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.MISSING_PKCE_PARAMETERS,
message: 'Public clients require PKCE OAuth parameters'
});
};
AppError.insufficientACRValues = (foundValue) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INSUFFICIENT_ACR_VALUES,
message: 'Required Authentication Context Reference values could not be satisfied'
}, {
foundValue
});
};
AppError.backendServiceFailure = (service, operation) => { AppError.backendServiceFailure = (service, operation) => {
return new AppError({ return new AppError({
code: 500, code: 500,

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

@ -0,0 +1,37 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
const Joi = require('joi');
const validators = require('../routes/validators');
module.exports = (config) => {
return {
path: '/v1/authorization',
method: 'POST',
validate: {
payload: Joi.object({
response_type: Joi.string().valid('code').default('code'),
client_id: validators.clientId.required(),
assertion: validators.assertion.required(),
redirect_uri: Joi.string().max(256).uri({
scheme: ['http', 'https']
}).optional(),
scope: validators.scope.optional(),
state: Joi.string().max(256).required(),
access_type: Joi.string().valid('offline', 'online').default('online'),
code_challenge_method: validators.pkceCodeChallengeMethod.optional(),
code_challenge: validators.pkceCodeChallenge.optional(),
keys_jwe: validators.jwe.optional(),
acr_values: Joi.string().max(256).allow(null).optional()
}).and('code_challenge', 'code_challenge_method'),
response: Joi.object({
redirect: Joi.string(),
code: validators.authorizationCode,
state: Joi.string().max(256),
})
}
};
};

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

@ -28,6 +28,7 @@ module.exports = (log, config) => {
revokeRefreshTokenById: require('./revoke-refresh-token-by-id')(config), revokeRefreshTokenById: require('./revoke-refresh-token-by-id')(config),
getClientInfo: require('./client-info')(config), getClientInfo: require('./client-info')(config),
getScopedKeyData: require('./scoped-key-data')(config), getScopedKeyData: require('./scoped-key-data')(config),
createAuthorizationCode: require('./create-authorization-code')(config),
}); });
const api = new OAuthAPI(config.oauth.url, config.oauth.poolee); const api = new OAuthAPI(config.oauth.url, config.oauth.poolee);
@ -77,6 +78,15 @@ module.exports = (log, config) => {
} }
}, },
async createAuthorizationCode(sessionToken, oauthParams) {
oauthParams.assertion = await makeAssertionJWT(config, sessionToken);
try {
return await api.createAuthorizationCode(oauthParams);
} catch (err) {
throw mapOAuthError(log, err);
}
},
/* As we work through the process of merging oauth-server /* As we work through the process of merging oauth-server
* into auth-server, future methods we might want to include * into auth-server, future methods we might want to include
* here will be things like the following: * here will be things like the following:
@ -84,12 +94,6 @@ module.exports = (log, config) => {
async getClientInstances(account) { async getClientInstances(account) {
}, },
async createAuthorizationCode(account, params) {
}
async redeemAuthorizationCode(account, params) {
}
async checkAccessToken(token) { async checkAccessToken(token) {
} }

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

@ -20,15 +20,34 @@ module.exports = {
if (err instanceof error) { if (err instanceof error) {
return err; return err;
} }
if (! err.errno) {
// If there's no `errno`, it must be some sort of internal implementation error.
// Let it bubble up and be caught by the top-level unexpected-error-handling logic.
throw err;
}
switch (err.errno) { switch (err.errno) {
case 101: case 101:
return error.unknownClientId(err.clientId); return error.unknownClientId(err.clientId);
case 103:
return error.incorrectRedirectURI(err.redirectUri);
case 104:
return error.invalidToken();
case 108: case 108:
return error.invalidToken(); return error.invalidToken();
case 109:
return error.invalidRequestParameter(err.validation);
case 110:
return error.invalidResponseType();
case 114:
return error.invalidScopes(err.invalidScopes);
case 116: case 116:
return error.notPublicClient(); return error.notPublicClient();
case 118:
return error.missingPkceParameters();
case 119: case 119:
return error.staleAuthAt(err.authAt); return error.staleAuthAt(err.authAt);
case 120:
return error.insufficientACRValues(err.foundValue);
default: default:
log.warn('oauthdb.mapOAuthError', { log.warn('oauthdb.mapOAuthError', {
err: err, err: err,

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

@ -55,6 +55,27 @@ module.exports = (log, config, oauthdb) => {
return oauthdb.getScopedKeyData(sessionToken, request.payload); return oauthdb.getScopedKeyData(sessionToken, request.payload);
} }
}, },
{
method: 'POST',
path: '/oauth/authorization',
options: {
auth: {
strategy: 'sessionToken'
},
validate: {
payload: oauthdb.api.createAuthorizationCode.opts.validate.payload.keys({
assertion: Joi.forbidden()
})
},
response: {
schema: oauthdb.api.createAuthorizationCode.opts.validate.response
}
},
handler: async function (request) {
const sessionToken = request.auth.credentials;
return oauthdb.createAuthorizationCode(sessionToken, request.payload);
}
},
]; ];
return routes; return routes;
}; };

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

@ -82,8 +82,11 @@ module.exports.hexString = isA.string().regex(HEX_STRING);
module.exports.clientId = module.exports.hexString.length(16); module.exports.clientId = module.exports.hexString.length(16);
module.exports.accessToken = module.exports.hexString.length(64); module.exports.accessToken = module.exports.hexString.length(64);
module.exports.refreshToken = module.exports.hexString.length(64); module.exports.refreshToken = module.exports.hexString.length(64);
module.exports.authorizationCode = module.exports.hexString.length(64);
module.exports.scope = isA.string().max(256).regex(/^[a-zA-Z0-9 _\/.:-]+$/); module.exports.scope = isA.string().max(256).regex(/^[a-zA-Z0-9 _\/.:-]+$/);
module.exports.assertion = isA.string().min(50).max(10240).regex(/^[a-zA-Z0-9_\-\.~=]+$/); module.exports.assertion = isA.string().min(50).max(10240).regex(/^[a-zA-Z0-9_\-\.~=]+$/);
module.exports.pkceCodeChallengeMethod = isA.string().valid('S256');
module.exports.pkceCodeChallenge = isA.string().length(43).regex(module.exports.URL_SAFE_BASE_64);
module.exports.jwe = isA.string().max(1024) module.exports.jwe = isA.string().max(1024)
// JWE token format: 'protectedheader.encryptedkey.iv.cyphertext.authenticationtag' // JWE token format: 'protectedheader.encryptedkey.iv.cyphertext.authenticationtag'
.regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/); .regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/);

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

@ -988,6 +988,18 @@ module.exports = config => {
}); });
}; };
ClientApi.prototype.createAuthorizationCode = function (sessionTokenHex, oauthParams) {
return tokens.SessionToken.fromHex(sessionTokenHex)
.then((token) => {
return this.doRequest(
'POST',
`${this.baseURL}/oauth/authorization`,
token,
oauthParams
);
});
};
ClientApi.heartbeat = function (origin) { ClientApi.heartbeat = function (origin) {
return (new ClientApi(origin)).doRequest('GET', `${origin }/__heartbeat__`); return (new ClientApi(origin)).doRequest('GET', `${origin }/__heartbeat__`);
}; };

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

@ -680,5 +680,9 @@ module.exports = config => {
return this.api.consumeSigninCode(code, metricsContext); return this.api.consumeSigninCode(code, metricsContext);
}; };
Client.prototype.createAuthorizationCode = function (oauthParams) {
return this.api.createAuthorizationCode(this.sessionToken, oauthParams);
};
return Client; return Client;
}; };

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

@ -0,0 +1,276 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
const { assert } = require('chai');
const nock = require('nock');
const oauthdbModule = require('../../../lib/oauthdb');
const error = require('../../../lib/error');
const { mockLog } = require('../../mocks');
const MOCK_CLIENT_ID = '0123456789ABCDEF';
const mockSessionToken = {
emailVerified: true,
tokenVerified: true,
uid: 'ABCDEF123456',
lastAuthAt: () => Date.now(),
authenticationMethods: ['pwd', 'email'],
};
const mockConfig = {
publicUrl: 'https://accounts.example.com',
oauth: {
url: 'https://oauth.server.com',
secretKey: 'secret-key-oh-secret-key',
},
domain: 'accounts.example.com'
};
const mockOAuthServer = nock(mockConfig.oauth.url).defaultReplyHeaders({
'Content-Type': 'application/json'
});
describe('oauthdb/createAuthorizationCode', () => {
let oauthdb;
afterEach(async () => {
assert.ok(nock.isDone(), 'there should be no pending request mocks at the end of a test');
if (oauthdb) {
await oauthdb.close();
}
});
it('can use a sessionToken to return a code', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(200, {
redirect: 'http://localhost/mock/redirect',
code: '1111112222223333334444445555556611111122222233333344444455555566',
state: 'xyz',
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
const res = await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz'
});
assert.deepEqual(res, {
redirect: 'http://localhost/mock/redirect',
code: '1111112222223333334444445555556611111122222233333344444455555566',
state: 'xyz',
});
});
it('refuses to do response_type=token grants', async () => {
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
response_type: 'token'
});
assert.fail('should have thrown');
} catch (err) {
assert.ok(err);
assert.equal(err.errno, error.ERRNO.INTERNAL_VALIDATION_ERROR);
}
});
it('correctly maps errno 101 to "unknown client id"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 101,
clientId: MOCK_CLIENT_ID,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz'
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.UNKNOWN_CLIENT_ID);
assert.equal(err.output.payload.clientId, MOCK_CLIENT_ID);
}
});
it('correctly maps errno 103 to "incorrect redirect uri"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 103,
redirectUri: 'https://incorrect.redirect'
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
redirect_uri: 'https://incorrect.redirect'
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INCORRECT_REDIRECT_URI);
assert.equal(err.output.payload.redirectUri, 'https://incorrect.redirect');
}
});
it('correctly maps errno 104 to "invalid token"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 104,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_TOKEN);
}
});
it('correctly maps errno 108 to "invalid token"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 108,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_TOKEN);
}
});
it('correctly maps errno 109 to "invalid request parameter"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 109,
validation: ['error', 'details', 'here']
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_PARAMETER);
assert.deepEqual(err.output.payload.validation, ['error', 'details', 'here']);
}
});
it('correctly maps errno 110 to "invalid response type"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 110,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_RESPONSE_TYPE);
}
});
it('correctly maps errno 114 to "invalid scopes"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 114,
invalidScopes: 'special-scope',
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_SCOPES);
assert.equal(err.output.payload.invalidScopes, 'special-scope');
}
});
it('correctly maps errno 116 to "not public client"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 116,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.NOT_PUBLIC_CLIENT);
}
});
it('correctly maps errno 118 to "missing PKCE parameters"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 118,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.MISSING_PKCE_PARAMETERS);
}
});
it('correctly maps errno 119 to "stale auth timestamp"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 119,
authAt: 1234,
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.STALE_AUTH_AT);
assert.equal(err.output.payload.authAt, 1234);
}
});
it('correctly maps errno 120 to "insufficient ACR values"', async () => {
mockOAuthServer.post('/v1/authorization', body => true)
.reply(400, {
errno: 120,
foundValue: 'AAL1',
});
oauthdb = oauthdbModule(mockLog(), mockConfig);
try {
await oauthdb.createAuthorizationCode(mockSessionToken, {
client_id: MOCK_CLIENT_ID,
state: 'xyz',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INSUFFICIENT_ACR_VALUES);
assert.equal(err.output.payload.foundValue, 'AAL1');
}
});
});

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

@ -27,6 +27,21 @@ describe('/oauth/ routes', () => {
return response; return response;
} }
async function mockSessionToken(props = {}) {
const Token = require(`${ROOT_DIR}/lib/tokens/token`)(mockLog);
const SessionToken = require(`${ROOT_DIR}/lib/tokens/session_token`)(mockLog, Token, {
tokenLifetimes: {
sessionTokenWithoutDevice: 2419200000
}
});
return await SessionToken.create({
uid: uuid.v4('binary').toString('hex'),
email: 'foo@example.com',
emailVerified: true,
...props,
});
}
beforeEach(() => { beforeEach(() => {
mockLog = mocks.mockLog(); mockLog = mocks.mockLog();
}); });
@ -61,17 +76,7 @@ describe('/oauth/ routes', () => {
return { key: 'data' }; return { key: 'data' };
}) })
}); });
const Token = require(`${ROOT_DIR}/lib/tokens/token`)(mockLog); sessionToken = await mockSessionToken();
const SessionToken = require(`${ROOT_DIR}/lib/tokens/session_token`)(mockLog, Token, {
tokenLifetimes: {
sessionTokenWithoutDevice: 2419200000
}
});
sessionToken = await SessionToken.create({
uid: uuid.v4('binary').toString('hex'),
email: 'foo@example.com',
emailVerified: true,
});
const mockRequest = mocks.mockRequest({ const mockRequest = mocks.mockRequest({
credentials: sessionToken, credentials: sessionToken,
payload: { payload: {
@ -87,31 +92,42 @@ describe('/oauth/ routes', () => {
}); });
assert.deepEqual(resp, { key: 'data' }); assert.deepEqual(resp, { key: 'data' });
}); });
});
it('rejects an `assertion` parameter in the request payload', async () => {
const Token = require(`${ROOT_DIR}/lib/tokens/token`)(mockLog); describe('/oauth/authorization', () => {
const SessionToken = require(`${ROOT_DIR}/lib/tokens/session_token`)(mockLog, Token, {
tokenLifetimes: { it('calls oauthdb.createAuthorizationCode', async () => {
sessionTokenWithoutDevice: 2419200000 mockOAuthDB = mocks.mockOAuthDB({
} createAuthorizationCode: sinon.spy(async () => {
}); return {
sessionToken = await SessionToken.create({ redirect: 'bogus',
uid: uuid.v4('binary').toString('hex'), code: 'aaabbbccc',
email: 'foo@example.com', state: 'xyz'
emailVerified: true, };
})
}); });
sessionToken = await mockSessionToken();
const mockRequest = mocks.mockRequest({ const mockRequest = mocks.mockRequest({
credentials: sessionToken, credentials: sessionToken,
payload: { payload: {
assertion: 'a~b',
client_id: MOCK_CLIENT_ID, client_id: MOCK_CLIENT_ID,
scope: MOCK_SCOPES scope: MOCK_SCOPES,
state: 'xyz',
} }
}); });
const resp = await loadAndCallRoute('/account/scoped-key-data', mockRequest); const resp = await loadAndCallRoute('/oauth/authorization', mockRequest);
assert.deepEqual(resp, { key: 'data' }); assert.calledOnce(mockOAuthDB.createAuthorizationCode);
assert.calledWithExactly(mockOAuthDB.createAuthorizationCode, sessionToken, {
client_id: MOCK_CLIENT_ID,
scope: MOCK_SCOPES,
state: 'xyz',
});
assert.deepEqual(resp, {
redirect: 'bogus',
code: 'aaabbbccc',
state: 'xyz'
});
}); });
}); });
}); });

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

@ -91,6 +91,7 @@ const OAUTHDB_METHOD_NAMES = [
'revokeRefreshTokenById', 'revokeRefreshTokenById',
'getClientInfo', 'getClientInfo',
'getScopedKeyData', 'getScopedKeyData',
'createAuthorizationCode',
]; ];
const LOG_METHOD_NAMES = [ const LOG_METHOD_NAMES = [

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

@ -0,0 +1,87 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
const { assert } = require('chai');
const TestServer = require('../test_server');
const Client = require('../client')();
const config = require('../../config').getProperties();
const error = require('../../lib/error');
const testUtils = require('../lib/util');
const oauthServerModule = require('../../fxa-oauth-server/lib/server');
const PUBLIC_CLIENT_ID = '3c49430b43dfba77';
const MOCK_CODE_CHALLENGE = '1234567890123456789012345678901234567890123';
describe('/oauth/authorization', function () {
this.timeout(15000);
let client;
let email;
let oauthServer;
let password;
let server;
before(async () => {
testUtils.disableLogs();
oauthServer = await oauthServerModule.create();
await oauthServer.start();
server = await TestServer.start(config, false, {oauthServer});
});
after(async () => {
await TestServer.stop(server);
await oauthServer.stop();
testUtils.restoreStdoutWrite();
});
beforeEach(async () => {
email = server.uniqueEmail();
password = 'test password';
client = await Client.createAndVerify(config.publicUrl, email, password, server.mailbox);
});
it('successfully grants an authorization code', async () => {
const res = await client.createAuthorizationCode({
client_id: PUBLIC_CLIENT_ID,
state: 'xyz',
code_challenge: MOCK_CODE_CHALLENGE,
code_challenge_method: 'S256',
});
assert.ok(res.redirect);
assert.ok(res.code);
assert.equal(res.state, 'xyz');
});
it('rejects invalid sessionToken', async () => {
const sessionToken = client.sessionToken;
await client.destroySession();
client.sessionToken = sessionToken;
try {
await client.createAuthorizationCode({
client_id: PUBLIC_CLIENT_ID,
state: 'xyz',
code_challenge: MOCK_CODE_CHALLENGE,
code_challenge_method: 'S256',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_TOKEN);
}
});
it('rejects `assertion` parameter in /authorization request', async () => {
try {
await client.createAuthorizationCode({
client_id: PUBLIC_CLIENT_ID,
state: 'xyz',
assertion: 'a~b'
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INVALID_PARAMETER);
assert.equal(err.validation.keys[0], 'assertion', 'assertion param caught in validation');
}
});
});