Skip to content
This repository has been archived by the owner on Apr 3, 2019. It is now read-only.

Commit

Permalink
Merge pull request #2932 from mozilla/oauth-authorize-with-session-token
Browse files Browse the repository at this point in the history
feat(oauth): Add /oauth/authorization route, authenticated with a sessionToken
  • Loading branch information
vladikoff authored Mar 27, 2019
2 parents 9564168 + c3bb754 commit 2bf8dd2
Show file tree
Hide file tree
Showing 13 changed files with 635 additions and 34 deletions.
65 changes: 65 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions lib/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions lib/oauthdb/create-authorization-code.js
Original file line number Diff line number Diff line change
@@ -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),
})
}
};
};
16 changes: 10 additions & 6 deletions lib/oauthdb/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -77,19 +78,22 @@ 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:
async getClientInstances(account) {
},
async createAuthorizationCode(account, params) {
}
async redeemAuthorizationCode(account, params) {
}
async checkAccessToken(token) {
}
Expand Down
19 changes: 19 additions & 0 deletions lib/oauthdb/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions lib/routes/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
3 changes: 3 additions & 0 deletions lib/routes/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-_]+$/);
Expand Down
12 changes: 12 additions & 0 deletions test/client/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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__`);
};
Expand Down
4 changes: 4 additions & 0 deletions test/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading

0 comments on commit 2bf8dd2

Please sign in to comment.