diff --git a/docs/api.md b/docs/api.md index 4eefcaee..1f14c58f 100644 --- a/docs/api.md +++ b/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. +#### POST /oauth/authorization + +:lock: HAWK-authenticated with session token + +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. + + + ### Password #### POST /password/change/start diff --git a/lib/error.js b/lib/error.js index d0f5370f..36a82197 100644 --- a/lib/error.js +++ b/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, diff --git a/lib/oauthdb/create-authorization-code.js b/lib/oauthdb/create-authorization-code.js new file mode 100644 index 00000000..fcc6d0d7 --- /dev/null +++ b/lib/oauthdb/create-authorization-code.js @@ -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), + }) + } + }; +}; diff --git a/lib/oauthdb/index.js b/lib/oauthdb/index.js index 675f67b7..bd112fce 100644 --- a/lib/oauthdb/index.js +++ b/lib/oauthdb/index.js @@ -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) { } diff --git a/lib/oauthdb/utils.js b/lib/oauthdb/utils.js index 09ea0068..04fff3da 100644 --- a/lib/oauthdb/utils.js +++ b/lib/oauthdb/utils.js @@ -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, diff --git a/lib/routes/oauth.js b/lib/routes/oauth.js index c26216a0..d5448fca 100644 --- a/lib/routes/oauth.js +++ b/lib/routes/oauth.js @@ -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; }; diff --git a/lib/routes/validators.js b/lib/routes/validators.js index 6f03f307..fbc8a17c 100644 --- a/lib/routes/validators.js +++ b/lib/routes/validators.js @@ -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-_]+$/); diff --git a/test/client/api.js b/test/client/api.js index d778187e..993af517 100644 --- a/test/client/api.js +++ b/test/client/api.js @@ -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__`); }; diff --git a/test/client/index.js b/test/client/index.js index ee10fa75..7fd5e95c 100644 --- a/test/client/index.js +++ b/test/client/index.js @@ -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; }; diff --git a/test/local/oauthdb/create-authorization-code.js b/test/local/oauthdb/create-authorization-code.js new file mode 100644 index 00000000..b2e13caf --- /dev/null +++ b/test/local/oauthdb/create-authorization-code.js @@ -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'); + } + }); +}); diff --git a/test/local/routes/oauth.js b/test/local/routes/oauth.js index f959f64a..bb6dc041 100644 --- a/test/local/routes/oauth.js +++ b/test/local/routes/oauth.js @@ -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' + }); }); - }); - }); diff --git a/test/mocks.js b/test/mocks.js index 31df5ce9..eee8ee54 100644 --- a/test/mocks.js +++ b/test/mocks.js @@ -91,6 +91,7 @@ const OAUTHDB_METHOD_NAMES = [ 'revokeRefreshTokenById', 'getClientInfo', 'getScopedKeyData', + 'createAuthorizationCode', ]; const LOG_METHOD_NAMES = [ diff --git a/test/remote/oauth_tests.js b/test/remote/oauth_tests.js new file mode 100644 index 00000000..9aba23e1 --- /dev/null +++ b/test/remote/oauth_tests.js @@ -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'); + } + }); +});