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 #2983 from mozilla/oauth-grant-with-session-token
Browse files Browse the repository at this point in the history
feat(oauth): Add /oauth/token route, optionally authed via sessionToken
  • Loading branch information
vladikoff authored Mar 27, 2019
2 parents 2bf8dd2 + 5f23915 commit 57f5891
Show file tree
Hide file tree
Showing 18 changed files with 981 additions and 10 deletions.
97 changes: 95 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ see [`mozilla/fxa-js-client`](https://github.com/mozilla/fxa-js-client).
* [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)
* [POST /oauth/token (:lock::unlock: sessionToken)](#post-oauthtoken)
* [Password](#password)
* [POST /password/change/start](#post-passwordchangestart)
* [POST /password/change/finish (:lock: passwordChangeToken)](#post-passwordchangefinish)
Expand Down Expand Up @@ -386,19 +387,22 @@ those common validations are defined here.
* `HEX_STRING`: `/^(?:[a-fA-F0-9]{2})+$/`
* `BASE_36`: `/^[a-zA-Z0-9]*$/`
* `URL_SAFE_BASE_64`: `/^[A-Za-z0-9_-]+$/`
* `PKCE_CODE_VERIFIER`: `/^[A-Za-z0-9-\._~]{43,128}$/`
* `DISPLAY_SAFE_UNICODE`: `/^(?:[^\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uD800-\uDFFF\uE000-\uF8FF\uFFF9-\uFFFF])*$/`
* `DISPLAY_SAFE_UNICODE_WITH_NON_BMP`: `/^(?:[^\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFF])*$/`
* `BEARER_AUTH_REGEX`: `/^Bearer\s+([a-z0-9+\/]+)$/i`
* `service`: `string, max(16), regex(/^[a-zA-Z0-9\-]*$/)`
* `hexString`: `string, regex(/^(?:[a-fA-F0-9]{2})+$/)`
* `clientId`: `module.exports.hexString.length(16)`
* `clientSecret`: `module.exports.hexString`
* `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 _\/.:-]+$/)`
* `scope`: `string, max(256), regex(/^[a-zA-Z0-9 _\/.:-]*$/), allow('')`
* `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)`
* `pkceCodeVerifier`: `string, length(43), regex(module, exports.PKCE_CODE_VERIFIER)`
* `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 @@ -1975,7 +1979,7 @@ rather than with a BrowserID assertion.

##### Request body

* `client_id`: *validators.email.required*
* `client_id`: *validators.clientId.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
Expand Down Expand Up @@ -2009,6 +2013,95 @@ rather than with a BrowserID assertion.
<!--end-route-post-oauthauthorization-->


#### POST /oauth/token

:lock::unlock: Optionally HAWK-authenticated with session token
<!--begin-route-post-oauthtoken-->
Grant new OAuth tokens for use by a connected client, using one of the following
grant types:
* `grant_type=authorization_code`: A single-use code obtained via OAuth redirect flow.
* `grant_type=refresh_token`: A refresh token issued by a previous call to this endpoint.
* `grant_type=fxa-credentials`: Directly grant tokens using an FxA sessionToken.

This is the "token endpoint" as defined in RFC6749, ande behaves like the
(oauth-server /token endpoint)[../fxa-oauth-server/docs/api.md#post-v1token]
except that the `fxa-credentials` grant can be authenticated directly with a sessionToken
rather than with a BrowserID assertion.

##### Request body

* `client_id`: *validators.clientId, required*
The OAuth client identifier for the requesting client application.
* `client_secret`: *validators.hexString, optional*
The OAuth client secret for the requesting client application. Required for confidential clients,
forbidden for public clients.
* `ttl`: *number.integer.min(0), optional*
The desired lifetime of the issued access token, in seconds.
The actual lifetime may be smaller than requested depending on server configuration,
and will be returned in the `expired_in` property of the response.
* `grant_type`: *string.allow('authorization_code', 'refresh_token', 'fxa-credentials'), optional*
The type of grant flow being used. If not specified, it will default to `fxa-credentials` unless a `code`
parameter is provided, in which case it will default to `authorization_code`.
The value of this parameter determines which other parameters will be expected in the request body,
as follows:
* When `grant_type=authorization_code`:
* `code`: *validators.authorizationCode, required*
The authorization code previously obtained through a redirect-based OAuth flow.
* `code_verifier`: *validators.pkceCodeVerifier, optional*
The [PKCE](../fxa-oauth-server/docs/pkce.md) code verifier used when obtaining `code`.
This is required for public OAuth clients, who must authenticate their authorization code use via PKCE.
* `redirect_uri`: *string, URI, optional*
The URI at which the client received the authorization code.
If supplied this *must* match the value provided during OAuth client registration.
* When `grant_type=refresh_token`:
* `refresh_token`: *validators.refreshToken, required*
A refresh token, as issued by a previous call to this endpoint.
* `scope`: *string, optional*
A space-separated list of scope values that will be held by the generated token.
These must be a subset of the scopes originally granted when the refresh token was generated.
* When `grant_type=fxa-credentials`:
* `scope`: *string, optional*
A space-separated list of scope values that will be held by the generated tokens.
* `access_type`: *string, valid(online, offline), optional*
If specified, a value of `offline` will cause the client to be granted a refresh token
alongside its access token.
* In addition, the request must be authenticated with a sessionToken.

##### Response body

* `access_token`: *validators.accessToken, required*
An OAuth access token that the client can use to access data associated with the user's account.
* `refresh_token`: *validators.refreshToken, optional*
A token that can be used to grant a new access token when the current one expires,
via `grant_type=refresh_token` on this endpoint.
* `id_token`: *validators.assertion, optional*
Open OpenID Connect identity token, provisioned if the `openid` scope was requested.
* `scope`: *string, required*
The scope values held by the granted access token.
* `auth_at`: *number, optional*
Where available, the timestamp at which the user last authenticated to FxA,
in seconds since the epoch.
* `token_type`: *string.allow('bearer'), required*
The type of token, which determins how the client should use it in subsequent requests.
Currently only Bearer tokens are supported.
* `expires_in`: *number.integer.min(0), required*
The number of seconds until the access token will expire.

<!--end-route-post-oauthtoken-->

##### Error responses

Failing requests may be caused
by the following errors
(this is not an exhaustive list):

* `code: 401, errno: 110`:
Invalid authentication token in request signature

* `code: 500, errno: 998`:
An internal validation check failed.


### Password

#### POST /password/change/start
Expand Down
4 changes: 2 additions & 2 deletions fxa-oauth-server/config/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
"hashedSecret": "a7ee3482fab1782f5d3945cde06bb911605a8dfc1a45e4b77bc76615d5671e51",
"imageUri": "",
"redirectUri": "http://127.0.0.1:3030/oauth/success/3c49430b43dfba77",
"canGrant": false,
"canGrant": true,
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/oldsync",
"publicClient": true
Expand All @@ -155,7 +155,7 @@
"hashedSecret": "4a892c55feaceb4ef2dbfffaaaa3d8eea94b5c205c815dddfc90170741cd4c19",
"imageUri": "",
"redirectUri": "http://127.0.0.1:3030/oauth/success/a2270f727f45f648",
"canGrant": false,
"canGrant": true,
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/oldsync",
"publicClient": true
Expand Down
62 changes: 62 additions & 0 deletions lib/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ const ERRNO = {
INVALID_RESPONSE_TYPE: 168,
MISSING_PKCE_PARAMETERS: 169,
INSUFFICIENT_ACR_VALUES: 170,
INCORRECT_CLIENT_SECRET: 171,
UNKNOWN_AUTHORIZATION_CODE: 172,
MISMATCH_AUTHORIZATION_CODE: 173,
EXPIRED_AUTHORIZATION_CODE: 174,
INVALID_PKCE_CHALLENGE: 175,

SERVER_BUSY: 201,
FEATURE_NOT_ENABLED: 202,
Expand Down Expand Up @@ -902,6 +907,17 @@ AppError.unknownClientId = (clientId) => {
});
};

AppError.incorrectClientSecret = (clientId) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INCORRECT_CLIENT_SECRET,
message: 'Incorrect client_secret'
}, {
clientId
});
};

AppError.staleAuthAt = (authAt) => {
return new AppError({
code: 400,
Expand Down Expand Up @@ -942,6 +958,41 @@ AppError.incorrectRedirectURI = (redirectUri) => {
});
};

AppError.unknownAuthorizationCode = (code) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.UNKNOWN_AUTHORIZATION_CODE,
message: 'Unknown authorization code'
}, {
code
});
};

AppError.mismatchAuthorizationCode = (code, clientId) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.MISMATCH_AUTHORIZATION_CODE,
message: 'Mismatched authorization code'
}, {
code,
clientId
});
};

AppError.expiredAuthorizationCode = (code, expiredAt) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.EXPIRED_AUTHORIZATION_CODE,
message: 'Expired authorization code'
}, {
code,
expiredAt
});
};

AppError.invalidResponseType = () => {
return new AppError({
code: 400,
Expand Down Expand Up @@ -971,6 +1022,17 @@ AppError.missingPkceParameters = () => {
});
};

AppError.invalidPkceChallenge = (pkceHashValue) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_PKCE_CHALLENGE,
message: 'Public clients require PKCE OAuth parameters'
}, {
pkceHashValue
});
};

AppError.insufficientACRValues = (foundValue) => {
return new AppError({
code: 400,
Expand Down
38 changes: 38 additions & 0 deletions lib/oauthdb/grant-tokens-from-authorization-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* 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/token',
method: 'POST',
validate: {
payload: Joi.object({
grant_type: Joi.string().valid('authorization_code').default('authorization_code'),
client_id: validators.clientId.required(),
client_secret: validators.clientSecret.optional(),
code: validators.authorizationCode.required(),
code_verifier: validators.pkceCodeVerifier.optional(),
redirect_uri: validators.url().optional(),
// Note: the max allowed TTL is currently configured in oauth-server config,
// making it hard to know what limit to set here.
ttl: Joi.number().positive().optional(),
}).xor('client_secret', 'code_verifier'),
response: Joi.object({
access_token: validators.accessToken.required(),
refresh_token: validators.refreshToken.optional(),
id_token: validators.assertion.optional(),
scope: validators.scope.required(),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().required(),
auth_at: Joi.number().required(),
keys_jwe: validators.jwe.optional()
})
}
};
};
36 changes: 36 additions & 0 deletions lib/oauthdb/grant-tokens-from-credentials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* 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/token',
method: 'POST',
validate: {
payload: Joi.object({
grant_type: Joi.string().valid('fxa-credentials').default('fxa-credentials'),
client_id: validators.clientId.required(),
assertion: validators.assertion.required(),
scope: validators.scope.optional(),
access_type: Joi.string().valid('online', 'offline').default('online'),
// Note: the max allowed TTL is currently configured in oauth-server config,
// making it hard to know what limit to set here.
ttl: Joi.number().positive().optional(),
}),
response: Joi.object({
access_token: validators.accessToken.required(),
refresh_token: validators.refreshToken.optional(),
id_token: validators.assertion.optional(),
scope: validators.scope.required(),
auth_at: Joi.number().required(),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().required()
})
}
};
};
33 changes: 33 additions & 0 deletions lib/oauthdb/grant-tokens-from-refresh-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* 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/token',
method: 'POST',
validate: {
payload: Joi.object({
grant_type: Joi.string().valid('refresh_token').required(),
client_id: validators.clientId.required(),
client_secret: validators.clientSecret.optional(),
refresh_token: validators.refreshToken.required(),
scope: validators.scope.optional(),
// Note: the max allowed TTL is currently configured in oauth-server config,
// making it hard to know what limit to set here.
ttl: Joi.number().positive().optional(),
}),
response: Joi.object({
access_token: validators.accessToken.required(),
scope: validators.scope.required(),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().required()
})
}
};
};
28 changes: 28 additions & 0 deletions lib/oauthdb/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ module.exports = (log, config) => {
getClientInfo: require('./client-info')(config),
getScopedKeyData: require('./scoped-key-data')(config),
createAuthorizationCode: require('./create-authorization-code')(config),
grantTokensFromAuthorizationCode: require('./grant-tokens-from-authorization-code')(config),
grantTokensFromRefreshToken: require('./grant-tokens-from-refresh-token')(config),
grantTokensFromCredentials: require('./grant-tokens-from-credentials')(config),
});

const api = new OAuthAPI(config.oauth.url, config.oauth.poolee);
Expand Down Expand Up @@ -87,6 +90,31 @@ module.exports = (log, config) => {
}
},

async grantTokensFromAuthorizationCode(oauthParams) {
try {
return await api.grantTokensFromAuthorizationCode(oauthParams);
} catch (err) {
throw mapOAuthError(log, err);
}
},

async grantTokensFromRefreshToken(oauthParams) {
try {
return await api.grantTokensFromRefreshToken(oauthParams);
} catch (err) {
throw mapOAuthError(log, err);
}
},

async grantTokensFromSessionToken(sessionToken, oauthParams) {
oauthParams.assertion = await makeAssertionJWT(config, sessionToken);
try {
return await api.grantTokensFromCredentials(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:
Expand Down
Loading

0 comments on commit 57f5891

Please sign in to comment.