Skip to content

Commit

Permalink
feat: draft implementation of IETF JWT Access Token profile
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Aug 4, 2019
1 parent 98b379d commit e690462
Show file tree
Hide file tree
Showing 20 changed files with 621 additions and 85 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ enabled by default, check the configuration section on how to enable them.
- [RFC7592 - OAuth 2.0 Dynamic Client Registration Management Protocol][registration-management]

The following draft specifications are implemented by oidc-provider.
- [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens - draft 02][jwt-at]
- [JWT Response for OAuth Token Introspection - draft 05][jwt-introspection]
- [JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) - draft 02][jarm]
- [OAuth 2.0 Demonstration of Proof-of-Possession at the Application Layer (DPoP) - individual draft 02][dpop]
Expand Down Expand Up @@ -190,5 +191,6 @@ See the list of available emitted [event names](/docs/events.md) and their descr
[dpop]: https://tools.ietf.org/html/draft-fett-oauth-dpop-02
[resource-indicators]: https://tools.ietf.org/html/draft-ietf-oauth-resource-indicators-05
[jarm]: https://openid.net/specs/openid-financial-api-jarm-wd-02.html
[jwt-at]: https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-02
[support-patreon]: https://www.patreon.com/panva
[support-paypal]: https://www.paypal.me/panva
5 changes: 3 additions & 2 deletions certification/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ module.exports = {
'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'],
},
features: {
devInteractions: { enabled: false },
backchannelLogout: { enabled: true, ack: 4 },
devInteractions: { enabled: false },
ietfJWTAccessTokenProfile: { enabled: true, ack: 2 },
mTLS: {
enabled: true,
certificateBoundAccessTokens: true,
Expand Down Expand Up @@ -99,7 +100,7 @@ module.exports = {
return { 'urn:oidc-provider:example:foo': 'bar' };
},
formats: {
AccessToken: 'jwt',
AccessToken: 'jwt-ietf',
},
jwks: {
keys: [
Expand Down
21 changes: 19 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ If you or your business use oidc-provider, please consider becoming a [Patron][s
- [dPoP](#featuresdpop)
- [encryption](#featuresencryption)
- [frontchannelLogout](#featuresfrontchannellogout)
- [ietfJWTAccessTokenProfile](#featuresietfjwtaccesstokenprofile)
- [introspection](#featuresintrospection)
- [jwtIntrospection](#featuresjwtintrospection)
- [jwtResponseModes](#featuresjwtresponsemodes)
Expand Down Expand Up @@ -981,6 +982,21 @@ html>`;

</details>

### features.ietfJWTAccessTokenProfile

[draft-ietf-oauth-access-token-jwt-02](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-02) - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens

Enables the use of `jwt-ietf` JWT Access Token format



_**default value**_:
```js
{
enabled: false
}
```

### features.introspection

[RFC7662](https://tools.ietf.org/html/rfc7662) - OAuth 2.0 Token Introspection
Expand Down Expand Up @@ -2007,9 +2023,10 @@ _**default value**_:

This option allows to configure the token serialization format. The different values change how a client-facing token value is generated as well as what properties get sent to the adapter for storage.
- `opaque` (default) formatted tokens store every property as a root property in your adapter
- `jwt` formatted tokens are issued as JWTs and stored the same as `opaque` only with additional property `jwt`. See `formats.jwtAccessTokenSigningAlg` for resolving the JWT Access Token signing algorithm. Note this is NOT an implementation of [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-01) IETF draft but a proprietary format that will eventually get deprecated in favour of the aforementioned IETF format, once it gets stable and implemented that is.
- `jwt` formatted tokens are issued as JWTs and stored the same as `opaque` only with additional property `jwt`. See `formats.jwtAccessTokenSigningAlg` for resolving the JWT Access Token signing algorithm. Note this is a proprietary format that will eventually get deprecated in favour of the 'jwt-ietf' value (once it gets stable and close to being an RFC)
- `jwt-ietf` formatted tokens are issued as JWTs and stored the same as `opaque` only with additional property `jwt-ietf`. See `formats.jwtAccessTokenSigningAlg` for resolving the JWT Access Token signing algorithm. This is an implementation of [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-02) draft and to enable it you need to enable `features.ietfJWTAccessTokenProfile`. 'jwt-ietf' value (once it gets stable and close to being an RFC)
- `paseto` formatted tokens are issued as v2.public PASETOs and stored the same as `opaque` only with additional property `paseto`. The server must have an `OKP Ed25519` key available to sign with else it will throw a server error. PASETOs are also allowed to only have a single audience, if the token's "aud" resolves with more than one the server will throw a server error.
- the value may also be a function dynamically determining the format (returning either `jwt`, `paseto` or `opaque` depending on the token itself)
- the value may also be a function dynamically determining the format (returning either `jwt`, `jwt-ietf`, `paseto` or `opaque` depending on the token itself)



Expand Down
8 changes: 7 additions & 1 deletion example/my_adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ class MyAdapter {
* - scope {string} - scope value from an authorization request, rejected scopes are removed
* from the value
* - sid {string} - session identifier the token comes from
* - x5t#S256 {string} - X.509 Certificate SHA-256 Thumbprint of a certificate bound access or
* - 'x5t#S256' {string} - X.509 Certificate SHA-256 Thumbprint of a certificate bound access or
* refresh token
* - 'jkt#S256' {string} - JWK SHA-256 Thumbprint (according to [RFC7638]) of a DPoP bound
* access or refresh token
* - gty {string} - [AccessToken, RefreshToken only] space delimited grant values, indicating
* the grant type(s) they originate from (implicit, authorization_code, refresh_token or
* device_code) the original one is always first, second is refresh_token if refreshed
Expand All @@ -89,6 +91,10 @@ class MyAdapter {
* - same as `opaque` with the addition of
* - jwt {string} - the JWT value returned to the client
*
* when `jwt-ietf`
* - same as `opaque` with the addition of
* - 'jwt-ietf' {string} - the JWT value returned to the client
*
* when `paseto`
* - same as `opaque` with the addition of
* - paseto {string} - the PASETO value returned to the client
Expand Down
7 changes: 0 additions & 7 deletions example/support/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,6 @@ module.exports = {
introspection: { enabled: true }, // defaults to false
revocation: { enabled: true }, // defaults to false
},
extraAccessTokenClaims(ctx, token) { // eslint-disable-line no-unused-vars
return { 'urn:oidc-provider:example:foo': 'bar' };
},
formats: {
AccessToken: 'jwt',
// ClientCredentials: 'jwt', not enabled
},
jwks: {
keys: [
{
Expand Down
25 changes: 20 additions & 5 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,16 @@ const DEFAULTS = {
*/
backchannelLogout: { enabled: false },

/*
* features.ietfJWTAccessTokenProfile
*
* title: [draft-ietf-oauth-access-token-jwt-02](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-02) - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
*
* description: Enables the use of `jwt-ietf` JWT Access Token format
*
*/
ietfJWTAccessTokenProfile: { enabled: false },


/*
* features.mTLS
Expand Down Expand Up @@ -1199,17 +1209,22 @@ const DEFAULTS = {
* - `jwt` formatted tokens are issued as JWTs and stored the same as `opaque` only with
* additional property `jwt`. See `formats.jwtAccessTokenSigningAlg` for resolving the JWT
* Access Token signing algorithm.
* Note this is NOT an implementation of
* [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-01)
* IETF draft but a proprietary format that will eventually get deprecated in favour of the
* aforementioned IETF format, once it gets stable and implemented that is.
* Note this is a proprietary format that will eventually get deprecated in favour of the
* 'jwt-ietf' value (once it gets stable and close to being an RFC)
* - `jwt-ietf` formatted tokens are issued as JWTs and stored the same as `opaque` only with
* additional property `jwt-ietf`. See `formats.jwtAccessTokenSigningAlg` for resolving the
* JWT Access Token signing algorithm.
* This is an implementation of
* [JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-02)
* draft and to enable it you need to enable `features.ietfJWTAccessTokenProfile`.
* 'jwt-ietf' value (once it gets stable and close to being an RFC)
* - `paseto` formatted tokens are issued as v2.public PASETOs and stored the same as `opaque`
* only with additional property `paseto`. The server must have an `OKP Ed25519` key available
* to sign with else it will throw a server error. PASETOs are also allowed to only have a
* single audience, if the token's "aud" resolves with more than one the server will throw a
* server error.
* - the value may also be a function dynamically determining the format (returning either
* `jwt`, `paseto` or `opaque` depending on the token itself)
* `jwt`, `jwt-ietf`, `paseto` or `opaque` depending on the token itself)
*
* example: To enable JWT Access Tokens
*
Expand Down
2 changes: 2 additions & 0 deletions lib/helpers/ensure_conform.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module.exports = function ensureConform(audience) {
'audiences must be an array with members or a single string value',
);

// TODO: in v7.x transform an array with a single member to a string

let value;
if (typeof audience === 'string') {
value = audience;
Expand Down
6 changes: 6 additions & 0 deletions lib/helpers/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const DRAFTS = new Map(Object.entries({
url: 'https://openid.net/specs/openid-connect-backchannel-1_0-04.html',
version: 4,
},
ietfJWTAccessTokenProfile: {
name: 'JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens - draft 02',
type: 'IETF OAuth Working Group draft',
url: 'https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-02',
version: 2,
},
mTLS: {
name: 'OAuth 2.0 Mutual TLS Client Authentication and Certificate-Bound Access Tokens - draft 15',
type: 'IETF OAuth Working Group draft',
Expand Down
2 changes: 1 addition & 1 deletion lib/helpers/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function verifyAudience({ aud, azp }, expected, checkAzp) {
class JWT {
// TODO: this does not need to be async anymore
static async sign(payload, key, alg, options = {}) {
const header = { alg, typ };
const header = { alg, typ: options.typ !== undefined ? options.typ : typ };
const timestamp = epochTime();

const iat = options.noIat ? undefined : timestamp;
Expand Down
81 changes: 37 additions & 44 deletions lib/models/formats/dynamic.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,46 @@
const { strict: assert } = require('assert');

const JWT = require('../../helpers/jwt');
const instance = require('../../helpers/weak_cache');
const ctxRef = require('../ctx_ref');

const jwt = require('./jwt');
const opaque = require('./opaque');
const paseto = require('./paseto');

const JWT_REGEX = /^(?:[a-zA-Z0-9-_]+\.){2}[a-zA-Z0-9-_]+$/;

module.exports = (provider) => {
const formats = {
jwt: jwt(provider),
opaque: opaque(provider),
paseto: paseto(provider),
};

return {
generateTokenId(...args) {
const resolver = instance(provider).dynamic[this.constructor.name];
const format = resolver(ctxRef.get(this), this);
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
this.format = format;
return formats[format].generateTokenId.apply(this, args);
},
async getValueAndPayload(...args) {
const { format } = this;
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
return formats[format].getValueAndPayload.apply(this, args);
},
getTokenId(...args) {
let format;
const [value] = args;
if (value && (value.length === 27 || value.length === 43)) {
format = 'opaque';
} else if (value.startsWith('v2.public.')) {
format = 'paseto';
} else if (JWT_REGEX.test(value)) {
format = 'jwt';
module.exports = (provider, formats) => ({
generateTokenId(...args) {
const resolver = instance(provider).dynamic[this.constructor.name];
const format = resolver(ctxRef.get(this), this);
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
this.format = format;
return formats[format].generateTokenId.apply(this, args);
},
async getValueAndPayload(...args) {
const { format } = this;
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
return formats[format].getValueAndPayload.apply(this, args);
},
getTokenId(...args) {
let format;
const [value] = args;
if (value && (value.length === 27 || value.length === 43)) {
format = 'opaque';
} else if (value.startsWith('v2.public.')) {
format = 'paseto';
} else if (JWT_REGEX.test(value)) {
if (JWT.header(value).typ === 'at+jwt') {
format = 'jwt-ietf';
} else {
format = 'opaque';
format = 'jwt';
}
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
return formats[format].getTokenId.apply(this, args);
},
async verify(...args) {
const format = args[1].format || (args[1].jwt ? 'jwt' : args[1].paseto ? 'paseto' : 'opaque'); // eslint-disable-line no-nested-ternary
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
return formats[format].verify.apply(this, args);
},
};
};
} else {
format = 'opaque';
}
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
return formats[format].getTokenId.apply(this, args);
},
async verify(...args) {
const format = args[1].format || (args[1].jwt ? 'jwt' : args[1]['jwt-ietf'] ? 'jwt-ietf' : args[1].paseto ? 'paseto' : 'opaque'); // eslint-disable-line no-nested-ternary
assert(formats[format] && format !== 'dynamic', 'invalid format resolved');
return formats[format].verify.apply(this, args);
},
});
23 changes: 18 additions & 5 deletions lib/models/formats/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
const instance = require('../../helpers/weak_cache');

const opaque = require('./opaque');
const jwt = require('./jwt');
const jwtIetf = require('./jwt_ietf');
const paseto = require('./paseto');
const dynamic = require('./dynamic');

module.exports = {
dynamic,
jwt,
paseto,
opaque,
module.exports = (provider) => {
const result = {
opaque: opaque(provider), // no dependencies
};

result.jwt = jwt(provider, result); // depends on opaque
result.paseto = paseto(provider, result); // depends on opaque

if (instance(provider).configuration('features.ietfJWTAccessTokenProfile.enabled')) {
result['jwt-ietf'] = jwtIetf(provider, result); // depends on opaque and jwt
}

result.dynamic = dynamic(provider, result); // depends on all

return result;
};
8 changes: 3 additions & 5 deletions lib/models/formats/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ const nanoid = require('../../helpers/nanoid');
const base64url = require('../../helpers/base64url');
const ctxRef = require('../ctx_ref');

const opaqueFormat = require('./opaque');

function getClaim(token, claim) {
return JSON.parse(base64url.decode(token.split('.')[1]))[claim];
}

module.exports = (provider) => {
const opaque = opaqueFormat(provider);

module.exports = (provider, { opaque }) => {
async function getSigningAlgAndKey(ctx, token, clientId) {
let client;
if (clientId) {
Expand All @@ -41,6 +37,8 @@ module.exports = (provider) => {
}

return {
getSigningAlgAndKey, // returning for it being reused via jwt_ietf

generateTokenId() {
return nanoid();
},
Expand Down
Loading

0 comments on commit e690462

Please sign in to comment.