Skip to content

Commit

Permalink
Merge pull request #52 from Authress/enable-override-token-generation
Browse files Browse the repository at this point in the history
Remove legacy ServiceClientTokenProvider use as a function.
  • Loading branch information
wparad authored Oct 11, 2024
2 parents 5479a71 + a3735e1 commit 2ba0a20
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 86 deletions.
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"request": "launch",
"name": "Debug tests",
"skipFiles": [
"<node_internals>/**"
],
"command": "npm test"
}
]
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This is the changelog for [Authress SDK](readme.md).
## 3.0 ##
* [Breaking] UserId is now required in all `userPermissions` apis. This improves **Security By Default** requiring explicit check on who the user is.
* [Breaking] Removal of property `accessToAllSubResources`.
* [Breaking] `ServiceClientTokenProvider` is now a first-class Javascript Class, it cannot be used as a function.

## 2.3 ##
* Require minimum Node version to be 16.
Expand Down
52 changes: 49 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,29 @@ import { Statement, LinkedGroup, User } from './src/records/dtos';
export * from './src/invites/api';
export * from './src/invites/dtos';

/**
* The Authress SDK primary settings object to be used with new AuthressClient.
* @export
* @interface AuthressSettings
*/
export interface AuthressSettings {
/**
* @deprecated Use the @see authressApiUrl property instead
*/
baseUrl?: string;

/** Authress baseUrl => API Host: https://authress.io/app/#/api?route=overview */
/**
* Authress baseUrl => API Host: https://authress.io/app/#/api?route=overview
* @type {string}
* @memberof AuthressSettings
*/
authressApiUrl?: string;

/** Set a custom user agent to identify this client, this is helpful for debugging problems and creating support tickets in the Authress Support Portal. */
/**
* Set a custom user agent to identify this client, this is helpful for debugging problems and creating support tickets in the Authress Support Portal.
* @type {string}
* @memberof AuthressSettings
*/
userAgent?: string;
}

Expand Down Expand Up @@ -1029,6 +1042,38 @@ export class AuthressClient {
verifyToken(jwtToken: string): Promise<Record<string, unknown>>;
}

/**
* ServiceClientTokenProvider getToken options.
* @export
* @interface GetTokenOptions
*/
export interface GetTokenOptions {
/** Override the generated token properties.
* @type {JwtOverrides}
* @memberof GetTokenOptions
*/
jwtOverrides?: JwtOverrides;
}

/**
* Override default JWT claims and properties.
* @export
* @interface JwtOverrides
*/
export interface JwtOverrides {
/** Specify Header custom properties to be used in conjunction with the ones generated by the library.
* @type {Record<string, unknown>}
* @memberof JwtOverrides
*/
header?: Record<string, unknown>;

/** Specify JWT payload custom claims and properties to be used in conjunction with the ones generated by the library.
* @type {Record<string, unknown>}
* @memberof JwtOverrides
*/
payload?: Record<string, unknown>;
}

/**
* ServiceClientTokenProvider
* @export
Expand All @@ -1047,9 +1092,10 @@ export class ServiceClientTokenProvider {
/**
* @summary Generate a token from this token provider. This is used indirectly by the SDK itself to generate tokens to authorize requests to Authress. It can also be used to generate tokens to authenticate to other services in your platform.
* @type {Function<Promise<string>>}
* @param {GetTokenOptions} options Configure the token creation properties such as overriding the properties and claims of the generated JWT with custom ones.
* @returns {Promise<string>} Generates a token to be used.
*/
getToken(): Promise<string>;
getToken(options?: GetTokenOptions): Promise<string>;

/**
* Generate the url to redirect the user back to your application from your authentication server after their credentials have been successfully verified. All these parameters should be found passed through from the user's login attempt along with their credentials. The authentication server receives a request from the user to login, with these values. Then these are constructed and sent back to Authress to verify the generated login data.
Expand Down
2 changes: 1 addition & 1 deletion jsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "nextjs",
"target": "esnext",
"module": "commonJS"
},
"exclude": []
Expand Down
9 changes: 7 additions & 2 deletions src/connectionsApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ const ArgumentRequiredError = require('./argumentRequiredError');
const jwtManager = require('./jwtManager');

async function getFallbackUser(httpClient) {
const token = await httpClient.tokenProvider();
let token;
if (typeof httpClient.tokenProvider === 'function') {
token = await httpClient.tokenProvider();
} else if (typeof httpClient.tokenProvider === 'object') {
token = await httpClient.tokenProvider.getToken();
}
const decodedJwt = jwtManager.decode(token);
return decodedJwt.sub;
return decodedJwt?.sub;
}

class ConnectionsApi {
Expand Down
8 changes: 7 additions & 1 deletion src/httpClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ class HttpClient {
const client = axios.create({ baseURL: this.baseUrl });

client.interceptors.request.use(async config => {
const token = await (typeof this.tokenProvider === 'function' ? this.tokenProvider(this.baseUrl) : this.tokenProvider.getToken(this.baseUrl));
let token;
if (typeof this.tokenProvider === 'function') {
token = await this.tokenProvider(this.baseUrl);
} else if (typeof this.tokenProvider === 'object') {
this.tokenProvider.authressCustomDomain = this.tokenProvider.authressCustomDomain || this.baseUrl;
token = await this.tokenProvider.getToken();
}
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`
Expand Down
64 changes: 34 additions & 30 deletions src/serviceClientTokenProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,45 @@ function getIssuer(unsanitizedAuthressCustomDomain, decodedAccessKey) {
return `${authressCustomDomain}/v1/clients/${encodeURIComponent(decodedAccessKey.clientId)}`;
}

module.exports = function(accessKey, authressCustomDomain) {
const accountId = accessKey.split('.')[2];
const decodedAccessKey = {
clientId: accessKey.split('.')[0], keyId: accessKey.split('.')[1],
audience: `${accountId}.accounts.authress.io`, privateKey: accessKey.split('.')[3]
};
class ServiceClientTokenProvider {
constructor(accessKey, authressCustomDomain) {
const accountId = accessKey.split('.')[2];
this.accountId = accountId;
this.authressCustomDomain = authressCustomDomain;
this.decodedAccessKey = {
clientId: accessKey.split('.')[0], keyId: accessKey.split('.')[1],
audience: `${accountId}.accounts.authress.io`, privateKey: accessKey.split('.')[3]
};
}

// Injects the custom domain in case the original service provider wasn't specified with it initially
const innerGetToken = async fallbackAuthressCustomDomain => {
async getToken(options = { jwtOverrides: { header: {}, payload: {} } }) {
if (this.cachedKeyData && this.cachedKeyData.token && this.cachedKeyData.expires > Date.now() + 3600000) {
return this.cachedKeyData.token;
}

// Do not set the issuer to be ${accountId}.api-region.authress.io it should always be set as the authress custom domain, the custom domain, or the generic api.authress.io one
const useFallbackAuthressCustomDomain = fallbackAuthressCustomDomain && !fallbackAuthressCustomDomain.match(/authress\.io/);
const useAuthressCustomDomain = this.authressCustomDomain && !this.authressCustomDomain.match(/authress\.io/);

const now = Math.round(Date.now() / 1000);
const jwt = {
aud: decodedAccessKey.audience,
iss: getIssuer(authressCustomDomain || useFallbackAuthressCustomDomain && fallbackAuthressCustomDomain || `${accountId}.api.authress.io`, decodedAccessKey),
sub: decodedAccessKey.clientId,
client_id: decodedAccessKey.clientId,
const jwt = Object.assign({
aud: this.decodedAccessKey.audience,
iss: getIssuer(useAuthressCustomDomain && this.authressCustomDomain || `${this.accountId}.api.authress.io`, this.decodedAccessKey),
sub: this.decodedAccessKey.clientId,
client_id: this.decodedAccessKey.clientId,
iat: now,
// valid for 24 hours
exp: now + 60 * 60 * 24,
scope: 'openid'
};
}, options?.jwtOverrides?.payload || {});

if (!decodedAccessKey.privateKey) {
if (!this.decodedAccessKey.privateKey) {
throw new InvalidAccessKeyError();
}

try {
const importedKey = createPrivateKey({ key: Buffer.from(decodedAccessKey.privateKey, 'base64'), format: 'der', type: 'pkcs8' });
const token = await new SignJWT(jwt).setProtectedHeader({ alg: 'EdDSA', kid: decodedAccessKey.keyId, typ: 'at+jwt' }).sign(importedKey);
const importedKey = createPrivateKey({ key: Buffer.from(this.decodedAccessKey.privateKey, 'base64'), format: 'der', type: 'pkcs8' });
const header = Object.assign({ alg: 'EdDSA', kid: this.decodedAccessKey.keyId, typ: 'at+jwt' }, options?.jwtOverrides?.header || {});
const token = await new SignJWT(jwt).setProtectedHeader(header).sign(importedKey);
this.cachedKeyData = { token, expires: jwt.exp * 1000 };
return token;
} catch (error) {
Expand All @@ -52,10 +56,9 @@ module.exports = function(accessKey, authressCustomDomain) {
}
throw error;
}
};
}

innerGetToken.getToken = innerGetToken;
innerGetToken.generateUserLoginUrl = async (authressCustomDomainLoginUrlInput, stateInput, clientIdInput, userIdInput) => {
async generateUserLoginUrl(authressCustomDomainLoginUrlInput, stateInput, clientIdInput, userIdInput) {
if (!authressCustomDomainLoginUrlInput) {
throw new ArgumentRequiredError('authressCustomDomainLoginUrl', 'The authressCustomDomainLoginUrl is not specified in the incoming login request, this should match the configured Authress custom domain.');
}
Expand All @@ -75,22 +78,22 @@ module.exports = function(accessKey, authressCustomDomain) {
if (!state) {
throw new ArgumentRequiredError('state', 'The state is required to generate a authorization code redirect for is required, and should be present in the authenticationUrl.');
}
if (!clientId || clientId !== decodedAccessKey.clientId) {
if (!clientId || clientId !== this.decodedAccessKey.clientId) {
throw new ArgumentRequiredError('clientId', 'The clientId should be specified in the authenticationUrl. It should match the service client ID.');
}
if (!userId) {
throw new ArgumentRequiredError('userId', 'The user to generate an authorization code redirect for is required.');
}

const customDomainFallback = new URL(authressCustomDomainLoginUrl).origin;
const issuer = getIssuer(authressCustomDomain || customDomainFallback, decodedAccessKey);
const issuer = getIssuer(this.authressCustomDomain || customDomainFallback, this.decodedAccessKey);

const now = Math.round(Date.now() / 1000);
const jwt = {
aud: decodedAccessKey.audience,
aud: this.decodedAccessKey.audience,
iss: issuer,
sub: userId,
client_id: decodedAccessKey.clientId,
client_id: this.decodedAccessKey.clientId,
iat: now,
exp: now + 60,
max_age: 60,
Expand All @@ -102,14 +105,15 @@ module.exports = function(accessKey, authressCustomDomain) {
jwt.email_verified = true;
}

const importedKey = createPrivateKey({ key: Buffer.from(decodedAccessKey.privateKey, 'base64'), format: 'der', type: 'pkcs8' });
const code = await new SignJWT(jwt).setProtectedHeader({ alg: 'EdDSA', kid: decodedAccessKey.keyId, typ: 'oauth-authz-req+jwt' }).sign(importedKey);
const importedKey = createPrivateKey({ key: Buffer.from(this.decodedAccessKey.privateKey, 'base64'), format: 'der', type: 'pkcs8' });
const code = await new SignJWT(jwt).setProtectedHeader({ alg: 'EdDSA', kid: this.decodedAccessKey.keyId, typ: 'oauth-authz-req+jwt' }).sign(importedKey);

const url = new URL(authressCustomDomainLoginUrl);
url.searchParams.set('code', code);
url.searchParams.set('iss', issuer);
url.searchParams.set('state', state);
return url.toString();
};
return innerGetToken;
};
}
}

module.exports = ServiceClientTokenProvider;
11 changes: 6 additions & 5 deletions src/tokenVerifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,15 @@ module.exports = async function(authressCustomDomainOrHttpClient, requestToken,
throw new TokenVerificationError('Unauthorized: No Issuer found');
}

const completeIssuerUrl = new URL(getSanitizedIssuerUrl(authressCustomDomain));
const altIssuerUrl = new URL(sanitizeUrl(authressCustomDomain));
const completeIssuerUrlOrigin = new URL(getSanitizedIssuerUrl(authressCustomDomain)).origin;
const altIssuerUrlOrigin = new URL(sanitizeUrl(authressCustomDomain)).origin;
const altGlobalIssuerUrlOrigin = new URL(sanitizeUrl(authressCustomDomain)).origin.replace(/^https:\/\/([a-z0-9-]+)[.][a-z0-9-]+[.]authress[.]io$/, 'https://$1.api.authress.io');
try {
if (new URL(issuer).origin !== completeIssuerUrl.origin && new URL(issuer).origin !== altIssuerUrl.origin) {
throw new TokenVerificationError(`Unauthorized: Invalid Issuer: ${issuer}`);
if (new URL(issuer).origin !== completeIssuerUrlOrigin && new URL(issuer).origin !== altIssuerUrlOrigin && new URL(issuer).origin !== altGlobalIssuerUrlOrigin) {
throw new TokenVerificationError(`Unauthorized: Invalid Issuer: ${issuer}, Expected: ${completeIssuerUrlOrigin}`);
}
} catch (error) {
throw new TokenVerificationError(`Unauthorized: Invalid Issuer: ${issuer}`);
throw new TokenVerificationError(`Unauthorized: Invalid Issuer: ${issuer}, Expected: ${completeIssuerUrlOrigin}`);
}

// Handle service client checking
Expand Down
7 changes: 0 additions & 7 deletions src/userPermissionsApi.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
const { URL, URLSearchParams } = require('url');
const jwtManager = require('./jwtManager');

const ArgumentRequiredError = require('./argumentRequiredError');
const UnauthorizedError = require('./unauthorizedError');

async function getFallbackUser(httpClient) {
const token = await httpClient.tokenProvider();
const decodedJwt = jwtManager.decode(token);
return decodedJwt.sub;
}

class UserPermissionsApi {
constructor(client) {
this.client = client;
Expand Down
Loading

0 comments on commit 2ba0a20

Please sign in to comment.