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

Commit

Permalink
feat(oauth): Use an assertion to directly grant tokens at /token.
Browse files Browse the repository at this point in the history
Clients could previously use an FxA assertion to grant themselves
OAuth access tokens via the /authorization endpoint in a style
modelled after OAuth's "implicit grant" flow. But it's not really
the implicit grant flow, the way we use it in practice much more
closely resembles the "resource owner password credentials" flow.
In particular, existing clients use it to directly create tokens
for their own use, rather than to authorize tokens for another client.

This commit makes that functionality available on the /token
endpoint instead, using `grant_type=fxa-credentials`. This is better
aligned with the way that the rest of OAuth works, closely mirroring
the `grant_type=password` flow and keeping a clear distinction
between obtaining tokens for ones own use (always use the /token
endpoint) versus authorizing them for someone else (always use the
/authorization endpoint).

It will hopefully help us avoid future footguns if we want to allow
assertion-bearing clients to create things for their own use (such
as refresh tokens) that are forbidden in the "implicit grant" flow
proper.
  • Loading branch information
rfk committed Mar 27, 2019
1 parent c74df5a commit 6db4efb
Show file tree
Hide file tree
Showing 12 changed files with 698 additions and 327 deletions.
48 changes: 33 additions & 15 deletions fxa-oauth-server/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The currently-defined error responses are:
| 400 | 118 | pkce parameters missing |
| 400 | 119 | stale authentication timestamp |
| 400 | 120 | mismatch acr value |
| 400 | 121 | invalid grant_type |
| 500 | 999 | internal server error |

## API Endpoints
Expand Down Expand Up @@ -329,6 +330,7 @@ back to the client. This code will be traded for a token at the
- `assertion`: A FxA assertion for the signed-in user.
- `state`: A value that will be returned to the client as-is upon redirection, so that clients can verify the redirect is authentic.
- `response_type`: Optional. If supplied, must be either `code` or `token`. `code` is the default. `token` means the implicit grant is desired, and requires that the client have special permission to do so.
- **Note: new implementations should not use `response_type=token`; instead use `grant_type=fxa-credentials` at the [token][] endpoint.**
- `ttl`: Optional if `response_type=token`, forbidden if `response_type=code`. Indicates the requested lifespan in seconds for the implicit grant token. The value is subject to an internal maximum limit, so clients must check the `expires_in` result property for the actual TTL.
- `redirect_uri`: Optional. If supplied, a string URL of where to redirect afterwards. Must match URL from registration.
- `scope`: Optional. A string-separated list of scopes that the user has authorized. This could be pruned by the user at the confirmation dialog.
Expand Down Expand Up @@ -377,33 +379,47 @@ If requesting an implicit grant (token), the response will match the

### POST /v1/token

After having received a [code][authorization], the client sends that code (most
likely a server-side request) to this endpoint, to receive a
longer-lived token that can be used to access attached services for a
particular user.
After receiving an authorization grant from the user, clients exercise that grant
at this endpoint to obtain tokens that can be used to access attached services
for a particular user.

#### Request Parameters
The following types of grant are possible:
- `authorization_code`: a single-use code as produced by the [authorization][] endpoint,
obtained through a redirect-based authorization flow.
- `refresh_token`: a token previously obtained from this endpoint when using
`access_type=offline`.
- `fxa-credentials`: an FxA identity assertion, obtained by directly authenticating
the user's account.

- `ttl`: (optional) Seconds that this access_token should be valid.
#### Request Parameters

The default and maximum value is 2 weeks.
- `grant_type`: Either `authorization_code`, `refresh_token`.
- `ttl`: (optional) Seconds that the access_token should be valid.
If unspecified this will default to the maximum value allowed by the
server, which is a configurable option but would typically be measured
in minutes or hours.
- `grant_type`: Either `authorization_code`, `refresh_token`, or `fxa-credentials`.
- If `authorization_code`:
- `client_id`: The id returned from client registration.
- `client_secret`: The secret returned from client registration.
Forbidden for public clients, required otherwise.
- `code`: A string that was received from the [authorization][] endpoint.
- `code_verifier`: The [PKCE](pkce.md) code verifier.
Required for public clients, forbidden otherwise.
- If `refresh_token`:
- `client_id`: The id returned from client registration.
- `client_secret`: The secret returned from client registration.
This must not be set if the client is a public (PKCE) client.
Forbidden for public (PKCE) clients, required otherwise.
- `refresh_token`: A string that received from the [token][]
endpoint specifically as a refresh token.
- `scope`: (optional) A subset of scopes provided to this
refresh_token originally, to receive an access_token with less
permissions.
- if client is type `publicClient:true` and `authorization_code`:
- `code_verifier`: Required if using [PKCE](pkce.md).

- If `fxa-credentials`:
- `client_id`: The id returned from client registration.
- `assertion`: FxA identity assertion authenticating the user.
- `scope`: (optional) A string-separated list of scopes to be authorized.
- `access_type`: (optional) Determines whether to generate a `refresh_token` (if `offline`)
or not (if `online`).


**Example:**
Expand All @@ -427,13 +443,15 @@ curl -v \
A valid request will return a JSON response with these properties:

- `access_token`: A string that can be used for authorized requests to service providers.
- `scope`: A string of space-separated permissions that this token has. May differ from requested scopes, since user can deny permissions.
- `refresh_token`: (Optional) A refresh token to fetch a new access token when this one expires. Only will be present if `grant_type=authorization_code` and the original authorization request included `access_type=offline`.
- `scope`: A string of space-separated permissions that this token has.
- `expires_in`: **Seconds** until this access token will no longer be valid.
- `token_type`: A string representing the token type. Currently will always be "bearer".
- `auth_at`: An integer giving the time at which the user authenticated to the Firefox Accounts server when generating this token, as a UTC unix timestamp (i.e. **seconds since epoch**).
- `refresh_token`: (Optional) A refresh token to fetch a new access token when this one expires. Only present if:
- `grant_type=authorization_code` and the original authorization request included `access_type=offline`.
- `grant_type=fxa-credentials` and the request included `access_type=offline`.
- `id_token`: (Optional) If the authorization was requested with `openid` scope, then this property will contain the OpenID Connect ID Token.
- `keys_jwe`: (Optional) Returns the JWE bundle that if the authorization request had one.
- `keys_jwe`: (Optional) Returns the JWE bundle of key material for any scopes that have keys, if `grant_type=authorization_code`.

**Example:**

Expand Down
2 changes: 1 addition & 1 deletion fxa-oauth-server/lib/db/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ MemoryStore.prototype = {
return P.resolve();
},
getScope: function getScope (scope) {
return P.resolve(this.scopes[scope]);
return P.resolve(this.scopes[scope] || null);
},
registerScope: function registerScope (scope) {
this.scopes[scope.scope] = scope;
Expand Down
9 changes: 7 additions & 2 deletions fxa-oauth-server/lib/db/mysql/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -824,8 +824,13 @@ MysqlStore.prototype = {
.then(() => this._write(QUERY_DELETE_REFRESH_TOKEN_FOR_PUBLIC_CLIENTS, [uid]));
},

getScope: function getScope (scope) {
return this._readOne(QUERY_SCOPE_FIND, [scope]);
getScope: async function getScope (scope) {
// We currently only have database entries for URL-format scopes,
// so don't bother hitting the db for common scopes like 'profile'.
if (! scope.startsWith('https://')) {
return null;
}
return await this._readOne(QUERY_SCOPE_FIND, [scope]) || null;
},

registerScope: function registerScope (scope) {
Expand Down
24 changes: 17 additions & 7 deletions fxa-oauth-server/lib/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const util = require('util');
const hex = require('buf').to.hex;

const DEFAULTS = {
code: 500,
Expand Down Expand Up @@ -82,7 +83,7 @@ AppError.unknownClient = function unknownClient(clientId) {
errno: 101,
message: 'Unknown client'
}, {
clientId: clientId
clientId: hex(clientId)
});
};

Expand All @@ -93,7 +94,7 @@ AppError.incorrectSecret = function incorrectSecret(clientId) {
errno: 102,
message: 'Incorrect secret'
}, {
clientId: clientId
clientId: hex(clientId)
});
};

Expand Down Expand Up @@ -135,8 +136,8 @@ AppError.mismatchCode = function mismatchCode(code, clientId) {
errno: 106,
message: 'Incorrect code'
}, {
requestCode: code,
client: clientId
requestCode: hex(code),
client: hex(clientId)
});
};

Expand All @@ -147,7 +148,7 @@ AppError.expiredCode = function expiredCode(code, expiredAt) {
errno: 107,
message: 'Expired code'
}, {
requestCode: code,
requestCode: hex(code),
expiredAt: expiredAt
});
};
Expand Down Expand Up @@ -233,14 +234,14 @@ AppError.expiredToken = function expiredToken(expiredAt) {
});
};

AppError.notPublicClient = function unknownClient(clientId) {
AppError.notPublicClient = function notPublicClient(clientId) {
return new AppError({
code: 400,
error: 'Bad Request',
errno: 116,
message: 'Not a public client'
}, {
clientId: clientId
clientId: hex(clientId)
});
};

Expand Down Expand Up @@ -285,4 +286,13 @@ AppError.mismatchAcr = function mismatchAcr(foundValue) {
}, {foundValue});
};

AppError.invalidGrantType = function invalidGrantType() {
return new AppError({
code: 400,
error: 'Bad Request',
errno: 121,
message: 'Invalid grant_type'
});
};

module.exports = AppError;
163 changes: 163 additions & 0 deletions fxa-oauth-server/lib/grant.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/* 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/. */

const buf = require('buf').hex;
const hex = require('buf').to.hex;

const config = require('./config');
const AppError = require('./error');
const db = require('./db');
const util = require('./util');
const ScopeSet = require('fxa-shared').oauth.scopes;
const JwTool = require('fxa-jwtool');

const ACR_VALUE_AAL2 = 'AAL2';
const ACCESS_TYPE_OFFLINE = 'offline';

const SCOPE_OPENID = ScopeSet.fromArray(['openid']);

const ID_TOKEN_EXPIRATION = Math.floor(config.get('openid.ttl') / 1000);
const ID_TOKEN_ISSUER = config.get('openid.issuer');
const ID_TOKEN_KEY = JwTool.JWK.fromObject(config.get('openid.key'), {
iss: ID_TOKEN_ISSUER
});

const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([
'openid',
'profile:uid',
'profile:email',
'profile:display_name'
]);

// Given a set of verified user identity claims, can the given client
// be granted the specified access to the user's data?
//
// This is a shared helper function responsible for checking:
// * whether the identity claims are sufficient to authorize the requested access
// * whether config allows that particular client to request such access at all
//
// It does *not* perform any user or client authentication, assuming that the
// authenticity of the passed-in details has been sufficiently verified by
// calling code.
module.exports.validateRequestedGrant = async function validateRequestedGrant(verifiedClaims, client, requestedGrant) {
requestedGrant.scope = requestedGrant.scope || ScopeSet.fromArray([]);

// If the grant request is for specific ACR values, do the identity claims support them?
if (requestedGrant.acr_values) {
const acrTokens = requestedGrant.acr_values.trim().split(/\s+/g);
if (acrTokens.includes(ACR_VALUE_AAL2) && ! (verifiedClaims['fxa-aal'] >= 2)) {
throw AppError.mismatchAcr(verifiedClaims['fxa-aal']);
}
}

// Is an untrusted client requesting scopes that it's not allowed?
if (! client.trusted) {
const invalidScopes = requestedGrant.scope.difference(UNTRUSTED_CLIENT_ALLOWED_SCOPES);
if (! invalidScopes.isEmpty()) {
throw AppError.invalidScopes(invalidScopes.getScopeValues());
}
}

// For key-bearing scopes, is the client allowed to request them?
// We probably want to clean this logic up in the future, but for now,
// all trusted clients are allowed to request all non-key-bearing scopes.
const scopeConfig = {};
const keyBearingScopes = ScopeSet.fromArray([]);
for (const scope of requestedGrant.scope.getScopeValues()) {
const s = scopeConfig[scope] = await db.getScope(scope);
if (s && s.hasScopedKeys) {
keyBearingScopes.add(scope);
}
}
if (! keyBearingScopes.isEmpty()) {
const invalidScopes = keyBearingScopes.difference(ScopeSet.fromString(client.allowedScopes || ''));
if (! invalidScopes.isEmpty()) {
throw AppError.invalidScopes(invalidScopes.getScopeValues());
}
// Any request for a key-bearing scope should be using a verified token,
// so we can also double-check that here as a defense-in-depth measure.
//
// Note that this directly reflects the `verified` property of the sessionToken
// used to create the assertion, so it can be true for e.g. sessions that were
// verified by email before 2FA was enabled on the account. Such sessions must
// be able to access sync even after 2FA is enabled, hence checking `verified`
// rather than the `aal`-related properties here.
if (! verifiedClaims['fxa-tokenVerified']) {
throw AppError.invalidAssertion();
}
}

// If we grow our per-client config, there are more things we could check here:
// * Is this client allowed to request ACCESS_TYPE_OFFLINE?
// * Is this client allowed to request all the non-key-bearing scopes?
// * Do we expect this client to be using OIDC?

return {
clientId: client.id,
userId: buf(verifiedClaims.uid),
email: verifiedClaims['fxa-verifiedEmail'],
scope: requestedGrant.scope,
scopeConfig,
offline: (requestedGrant.access_type === ACCESS_TYPE_OFFLINE),
authAt: verifiedClaims['fxa-lastAuthAt'],
amr: verifiedClaims['fxa-amr'],
aal: verifiedClaims['fxa-aal'],
profileChangedAt: verifiedClaims['fxa-profileChangedAt'],
keysJwe: requestedGrant.keys_jwe
};
};

// Generate tokens that will give the holder all the access in the specified grant.
// This always include an access_token, but may also include a refresh_token and/or
// id_token if implied by the grant.
//
// This function does *not* perform any authentication or validation, assuming that
// the specified grant has been sufficiently vetted by calling code.
module.exports.generateTokens = async function generateTokens(grant) {
// We always generate an access_token.
const access = await db.generateAccessToken(grant);
const result = {
access_token: access.token.toString('hex'),
token_type: access.type,
scope: access.scope.toString()
};
result.expires_in = grant.ttl || Math.floor((access.expiresAt - Date.now()) / 1000);
if (grant.authAt) {
result.auth_at = grant.authAt;
}
if (grant.keysJwe) {
result.keys_jwe = grant.keysJwe;
}
// Maybe also generate a refreshToken?
if (grant.offline) {
const refresh = await db.generateRefreshToken(grant);
result.refresh_token = refresh.token.toString('hex');
}
// Maybe also generate an idToken?
if (grant.scope && grant.scope.contains(SCOPE_OPENID)) {
result.id_token = await generateIdToken(grant, access);
}
return result;
};

function generateIdToken(grant, access) {
var now = Math.floor(Date.now() / 1000);
var claims = {
sub: hex(grant.userId),
aud: hex(grant.clientId),
iss: ID_TOKEN_ISSUER,
iat: now,
exp: now + ID_TOKEN_EXPIRATION,
at_hash: util.generateTokenHash(access.token)
};
if (grant.amr) {
claims.amr = grant.amr;
}
if (grant.aal) {
claims['fxa-aal'] = grant.aal;
claims.acr = 'AAL' + grant.aal;
}

return ID_TOKEN_KEY.sign(claims);
}
Loading

0 comments on commit 6db4efb

Please sign in to comment.