feat(oauth): Add /oauth/authorization route, authenticated with a sessionToken.
This commit is contained in:
Родитель
9564168b28
Коммит
c3bb754c57
65
docs/api.md
65
docs/api.md
|
@ -47,6 +47,7 @@ see [`mozilla/fxa-js-client`](https://github.com/mozilla/fxa-js-client).
|
|||
* [Oauth](#oauth)
|
||||
* [GET /oauth/client/{client_id}](#get-oauthclientclient_id)
|
||||
* [POST /account/scoped-key-data (:lock: sessionToken)](#post-accountscoped-key-data)
|
||||
* [POST /oauth/authorization (:lock: sessionToken)](#post-oauthauthorization)
|
||||
* [Password](#password)
|
||||
* [POST /password/change/start](#post-passwordchangestart)
|
||||
* [POST /password/change/finish (:lock: passwordChangeToken)](#post-passwordchangefinish)
|
||||
|
@ -303,6 +304,16 @@ for `code` and `errno` are:
|
|||
Redis WATCH detected a conflicting update
|
||||
* `code: 400, errno: 166`:
|
||||
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`:
|
||||
Service unavailable
|
||||
* `code: 503, errno: 202`:
|
||||
|
@ -337,6 +348,9 @@ include additional response properties:
|
|||
* `errno: 153`
|
||||
* `errno: 162`: clientId
|
||||
* `errno: 164`: authAt
|
||||
* `errno: 167`: redirectUri
|
||||
* `errno: 169`: invalidScopes
|
||||
* `errno: 171`: foundValue
|
||||
* `errno: 201`: retryAfter
|
||||
* `errno: 202`: retryAfter
|
||||
* `errno: 203`: service, operation
|
||||
|
@ -380,8 +394,11 @@ those common validations are defined here.
|
|||
* `clientId`: `module.exports.hexString.length(16)`
|
||||
* `accessToken`: `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 _\/.:-]+$/)`
|
||||
* `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-_]+$/)`
|
||||
* `verificationMethod`: `string, valid()`
|
||||
* `authPW`: `string, length(64), regex(HEX_STRING), required`
|
||||
|
@ -1944,6 +1961,54 @@ requested by the specified OAuth client.
|
|||
<!--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
|
||||
|
||||
#### POST /password/change/start
|
||||
|
|
56
lib/error.js
56
lib/error.js
|
@ -72,9 +72,14 @@ const ERRNO = {
|
|||
TOTP_REQUIRED: 160,
|
||||
RECOVERY_KEY_EXISTS: 161,
|
||||
UNKNOWN_CLIENT_ID: 162,
|
||||
INVALID_SCOPES: 163,
|
||||
STALE_AUTH_AT: 164,
|
||||
REDIS_CONFLICT: 165,
|
||||
NOT_PUBLIC_CLIENT: 166,
|
||||
INCORRECT_REDIRECT_URI: 167,
|
||||
INVALID_RESPONSE_TYPE: 168,
|
||||
MISSING_PKCE_PARAMETERS: 169,
|
||||
INSUFFICIENT_ACR_VALUES: 170,
|
||||
|
||||
SERVER_BUSY: 201,
|
||||
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) => {
|
||||
return new AppError({
|
||||
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),
|
||||
getClientInfo: require('./client-info')(config),
|
||||
getScopedKeyData: require('./scoped-key-data')(config),
|
||||
createAuthorizationCode: require('./create-authorization-code')(config),
|
||||
});
|
||||
|
||||
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
|
||||
* into auth-server, future methods we might want to include
|
||||
* here will be things like the following:
|
||||
|
@ -84,12 +94,6 @@ module.exports = (log, config) => {
|
|||
async getClientInstances(account) {
|
||||
},
|
||||
|
||||
async createAuthorizationCode(account, params) {
|
||||
}
|
||||
|
||||
async redeemAuthorizationCode(account, params) {
|
||||
}
|
||||
|
||||
async checkAccessToken(token) {
|
||||
}
|
||||
|
||||
|
|
|
@ -20,15 +20,34 @@ module.exports = {
|
|||
if (err instanceof error) {
|
||||
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) {
|
||||
case 101:
|
||||
return error.unknownClientId(err.clientId);
|
||||
case 103:
|
||||
return error.incorrectRedirectURI(err.redirectUri);
|
||||
case 104:
|
||||
return error.invalidToken();
|
||||
case 108:
|
||||
return error.invalidToken();
|
||||
case 109:
|
||||
return error.invalidRequestParameter(err.validation);
|
||||
case 110:
|
||||
return error.invalidResponseType();
|
||||
case 114:
|
||||
return error.invalidScopes(err.invalidScopes);
|
||||
case 116:
|
||||
return error.notPublicClient();
|
||||
case 118:
|
||||
return error.missingPkceParameters();
|
||||
case 119:
|
||||
return error.staleAuthAt(err.authAt);
|
||||
case 120:
|
||||
return error.insufficientACRValues(err.foundValue);
|
||||
default:
|
||||
log.warn('oauthdb.mapOAuthError', {
|
||||
err: err,
|
||||
|
|
|
@ -55,6 +55,27 @@ module.exports = (log, config, oauthdb) => {
|
|||
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;
|
||||
};
|
||||
|
|
|
@ -82,8 +82,11 @@ module.exports.hexString = isA.string().regex(HEX_STRING);
|
|||
module.exports.clientId = module.exports.hexString.length(16);
|
||||
module.exports.accessToken = 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.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)
|
||||
// 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-_]+$/);
|
||||
|
|
|
@ -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) {
|
||||
return (new ClientApi(origin)).doRequest('GET', `${origin }/__heartbeat__`);
|
||||
};
|
||||
|
|
|
@ -680,5 +680,9 @@ module.exports = config => {
|
|||
return this.api.consumeSigninCode(code, metricsContext);
|
||||
};
|
||||
|
||||
Client.prototype.createAuthorizationCode = function (oauthParams) {
|
||||
return this.api.createAuthorizationCode(this.sessionToken, oauthParams);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
mockLog = mocks.mockLog();
|
||||
});
|
||||
|
@ -61,17 +76,7 @@ describe('/oauth/ routes', () => {
|
|||
return { key: 'data' };
|
||||
})
|
||||
});
|
||||
const Token = require(`${ROOT_DIR}/lib/tokens/token`)(mockLog);
|
||||
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,
|
||||
});
|
||||
sessionToken = await mockSessionToken();
|
||||
const mockRequest = mocks.mockRequest({
|
||||
credentials: sessionToken,
|
||||
payload: {
|
||||
|
@ -87,31 +92,42 @@ describe('/oauth/ routes', () => {
|
|||
});
|
||||
assert.deepEqual(resp, { key: 'data' });
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an `assertion` parameter in the request payload', async () => {
|
||||
const Token = require(`${ROOT_DIR}/lib/tokens/token`)(mockLog);
|
||||
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,
|
||||
|
||||
describe('/oauth/authorization', () => {
|
||||
|
||||
it('calls oauthdb.createAuthorizationCode', async () => {
|
||||
mockOAuthDB = mocks.mockOAuthDB({
|
||||
createAuthorizationCode: sinon.spy(async () => {
|
||||
return {
|
||||
redirect: 'bogus',
|
||||
code: 'aaabbbccc',
|
||||
state: 'xyz'
|
||||
};
|
||||
})
|
||||
});
|
||||
sessionToken = await mockSessionToken();
|
||||
const mockRequest = mocks.mockRequest({
|
||||
credentials: sessionToken,
|
||||
payload: {
|
||||
assertion: 'a~b',
|
||||
client_id: MOCK_CLIENT_ID,
|
||||
scope: MOCK_SCOPES
|
||||
scope: MOCK_SCOPES,
|
||||
state: 'xyz',
|
||||
}
|
||||
});
|
||||
const resp = await loadAndCallRoute('/account/scoped-key-data', mockRequest);
|
||||
assert.deepEqual(resp, { key: 'data' });
|
||||
const resp = await loadAndCallRoute('/oauth/authorization', mockRequest);
|
||||
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',
|
||||
'getClientInfo',
|
||||
'getScopedKeyData',
|
||||
'createAuthorizationCode',
|
||||
];
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче