Skip to content

Commit

Permalink
Merge pull request #134 from badgateway/resource-indicators
Browse files Browse the repository at this point in the history
Support for Resource Indicators for OAuth 2.0.
  • Loading branch information
evert authored Jan 18, 2024
2 parents e165f3b + fdb4dd3 commit d37e518
Show file tree
Hide file tree
Showing 10 changed files with 275 additions and 51 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ since Node 18 (but it works with Polyfills on Node 14 and 16).

## Highlights

* 10KB minified (3.6KB gzipped).
* 11KB minified (3.7KB gzipped).
* No dependencies.
* `authorization_code` grant with optional [PKCE][1] support.
* `password` and `client_credentials` grant.
* a `fetch()` wrapper that automatically adds Bearer tokens and refreshes them.
* OAuth2 endpoint discovery via the Server metadata document ([RFC8414][2]).
* OAuth2 Token Introspection ([RFC7662][3]).
* Resource Indicators for OAuth 2.0 ([RFC8707][5]).


## Installation
Expand Down Expand Up @@ -438,3 +439,4 @@ if (global.btoa === undefined) {
[2]: https://datatracker.ietf.org/doc/html/rfc8414 "OAuth 2.0 Authorization Server Metadata"
[3]: https://datatracker.ietf.org/doc/html/rfc7662 "OAuth 2.0 Token Introspection"
[4]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API "Web Crypto API"
[5]: https://datatracker.ietf.org/doc/html/rfc8707 "https://datatracker.ietf.org/doc/html/rfc8707"
3 changes: 2 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
Changelog
=========

2.2.5 (????-??-??)
2.3.0 (????-??-??)
------------------

* Fix for #128: If there's no secret, we should never use Basic auth to encode
the `client_id`.
* Support for the 'resource' parameter from RFC 8707.


2.2.4 (2023-09-05)
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
"pkce",
"security",
"bearer",
"RFC6749"
"RFC6749",
"RFC7636",
"RFC7662",
"RFC8414",
"RFC8707"
],
"author": "Evert Pot (https://evertpot.com)",
"license": "MIT",
Expand Down
47 changes: 39 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,34 @@ import {
import { OAuth2Error } from './error';
import { OAuth2AuthorizationCodeClient } from './client/authorization-code';


type ClientCredentialsParams = {
scope?: string[];
extraParams?: Record<string, string>;

/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];
}

type PasswordParams = {
username: string;
password: string;

scope?: string[];

/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];

}

export interface ClientSettings {

/**
Expand Down Expand Up @@ -132,7 +160,7 @@ export class OAuth2Client {
/**
* Retrieves an OAuth2 token using the client_credentials grant.
*/
async clientCredentials(params?: { scope?: string[]; extraParams?: Record<string, string> }): Promise<OAuth2Token> {
async clientCredentials(params?: ClientCredentialsParams): Promise<OAuth2Token> {

const disallowed = ['client_id', 'client_secret', 'grant_type', 'scope'];

Expand All @@ -143,6 +171,7 @@ export class OAuth2Client {
const body: ClientCredentialsRequest = {
grant_type: 'client_credentials',
scope: params?.scope?.join(' '),
resource: params?.resource,
...params?.extraParams
};

Expand All @@ -157,7 +186,7 @@ export class OAuth2Client {
/**
* Retrieves an OAuth2 token using the 'password' grant'.
*/
async password(params: { username: string; password: string; scope?: string[] }): Promise<OAuth2Token> {
async password(params: PasswordParams): Promise<OAuth2Token> {

const body: PasswordRequest = {
grant_type: 'password',
Expand Down Expand Up @@ -386,12 +415,14 @@ function resolve(uri: string, base?: string): string {
*
* This function filters out any undefined values.
*/
export function generateQueryString(params: Record<string, undefined | number | string>): string {
export function generateQueryString(params: Record<string, undefined | number | string | string[]>): string {

return new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([k, v]) => v !== undefined)
) as Record<string, string>
).toString();
const query = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (Array.isArray(v)) {
for(const vItem of v) query.append(k, vItem);
} else if (v !== undefined) query.set(k, v.toString());
}
return query.toString();

}
64 changes: 45 additions & 19 deletions src/client/authorization-code.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OAuth2Client, generateQueryString } from '../client';
import { OAuth2Client } from '../client';
import { OAuth2Token } from '../token';
import { AuthorizationCodeRequest, AuthorizationQueryParams } from '../messages';
import { AuthorizationCodeRequest } from '../messages';
import { OAuth2Error } from '../error';

type GetAuthorizeUrlParams = {
Expand All @@ -27,6 +27,13 @@ type GetAuthorizeUrlParams = {
*/
scope?: string[];

/**
* The resource the client intends to access.
*
* This is defined in RFC 8707.
*/
resource?: string[] | string;

/**
* Any parameters listed here will be added to the query string for the authorization server endpoint.
*/
Expand All @@ -47,6 +54,23 @@ type ValidateResponseResult = {

}

type GetTokenParams = {

code: string;

redirectUri: string;
state?: string;
codeVerifier?:string;

/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string[] | string;

}

export class OAuth2AuthorizationCodeClient {

client: OAuth2Client;
Expand All @@ -71,34 +95,35 @@ export class OAuth2AuthorizationCodeClient {
this.client.getEndpoint('authorizationEndpoint')
]);

let query: AuthorizationQueryParams = {
const query = new URLSearchParams({
client_id: this.client.settings.clientId,
response_type: 'code',
redirect_uri: params.redirectUri,
code_challenge_method: codeChallenge?.[0],
code_challenge: codeChallenge?.[1],
};
});
if (codeChallenge) {
query.set('code_challenge_method', codeChallenge[0]);
query.set('code_challenge', codeChallenge[1]);
}
if (params.state) {
query.state = params.state;
query.set('state', params.state);
}
if (params.scope) {
query.scope = params.scope.join(' ');
query.set('scope', params.scope.join(' '));
}

const disallowed = Object.keys(query);

if (params?.extraParams && Object.keys(params.extraParams).filter((key) => disallowed.includes(key)).length > 0) {
throw new Error(`The following extraParams are disallowed: '${disallowed.join("', '")}'`);
if (params.resource) for(const resource of [].concat(params.resource as any)) {
query.append('resource', resource);
}
if (params.extraParams) for(const [k,v] of Object.entries(params.extraParams)) {
if (query.has(k)) throw new Error(`Property in extraParams would overwrite standard property: ${k}`);
query.set(k, v);
}

query = {...query, ...params?.extraParams};


return authorizationEndpoint + '?' + generateQueryString(query);
return authorizationEndpoint + '?' + query.toString();

}

async getTokenFromCodeRedirect(url: string|URL, params: {redirectUri: string; state?: string; codeVerifier?:string} ): Promise<OAuth2Token> {
async getTokenFromCodeRedirect(url: string|URL, params: Omit<GetTokenParams, 'code'> ): Promise<OAuth2Token> {

const { code } = await this.validateResponse(url, {
state: params.state
Expand Down Expand Up @@ -126,7 +151,7 @@ export class OAuth2AuthorizationCodeClient {
if (queryParams.has('error')) {
throw new OAuth2Error(
queryParams.get('error_description') ?? 'OAuth2 error',
queryParams.get('error')!,
queryParams.get('error') as any,
0,
);
}
Expand All @@ -148,13 +173,14 @@ export class OAuth2AuthorizationCodeClient {
/**
* Receives an OAuth2 token using 'authorization_code' grant
*/
async getToken(params: { code: string; redirectUri: string; codeVerifier?: string }): Promise<OAuth2Token> {
async getToken(params: GetTokenParams): Promise<OAuth2Token> {

const body:AuthorizationCodeRequest = {
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
code_verifier: params.codeVerifier,
resource: params.resource,
};
return this.client.tokenResponseToOAuth2Token(this.client.request('tokenEndpoint', body));

Expand Down
6 changes: 4 additions & 2 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { OAuth2ErrorCode } from './messages';

/**
* An error class for any error the server emits.
*
Expand All @@ -12,10 +14,10 @@
*/
export class OAuth2Error extends Error {

oauth2Code: string;
oauth2Code: OAuth2ErrorCode;
httpCode: number;

constructor(message: string, oauth2Code: string, httpCode: number) {
constructor(message: string, oauth2Code: OAuth2ErrorCode, httpCode: number) {

super(message);

Expand Down
58 changes: 43 additions & 15 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export type RefreshRequest = {

client_id?: string;
scope?: string;

/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];
}

/**
Expand All @@ -15,7 +22,15 @@ export type RefreshRequest = {
export type ClientCredentialsRequest = {
grant_type: 'client_credentials';
scope?: string;
[key: string]: string | undefined;

/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];

[key: string]: string | undefined | string[];
}

/**
Expand All @@ -26,28 +41,27 @@ export type PasswordRequest = {
username: string;
password: string;
scope?: string;

/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];
}

export type AuthorizationCodeRequest = {
grant_type: 'authorization_code';
code: string;
redirect_uri: string;
code_verifier: string|undefined;
}

/**
* The query parameters that will be sent to the /authorization endpoint
* for the authorization_code request.
*/
export type AuthorizationQueryParams = {
response_type: 'code';
client_id: string;
redirect_uri: string;
state?: string;
scope?: string;
code_challenge_method?: 'plain' | 'S256';
code_challenge?: string;
[key: string]: string | undefined;
/**
* The resource the client intends to access.
*
* @see https://datatracker.ietf.org/doc/html/rfc8707
*/
resource?: string | string[];
}

/**
Expand Down Expand Up @@ -266,3 +280,17 @@ export type IntrospectionResponse = {
jti?: string;

}

export type OAuth2ErrorCode =
| 'invalid_request'
| 'invalid_client'
| 'invalid_grant'
| 'unauthorized_client'
| 'unsupported_grant_type'
| 'invalid_scope'

/**
* RFC 8707
*/
| 'invalid_target';

Loading

0 comments on commit d37e518

Please sign in to comment.