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)
|
* [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
|
||||||
|
|
56
lib/error.js
56
lib/error.js
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
Загрузка…
Ссылка в новой задаче