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 #2969 from mozilla/oauth-assertion-grant; r=stomli…
Browse files Browse the repository at this point in the history
…nson,vladikoff

feat(oauth): Use an assertion to directly grant tokens at /token.
  • Loading branch information
rfk authored Mar 27, 2019
2 parents 67a95e2 + 6db4efb commit 9564168
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 9564168

Please sign in to comment.