diff --git a/.eslintrc b/.eslintrc index 111f16f..86b0ffd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -13,6 +13,11 @@ "extends": "@martin-kolarik/eslint-config/typescript-type-checking", "parserOptions": { "project": true + }, + "rules": { + "@typescript-eslint/consistent-type-imports": ["error", { + "prefer": "no-type-imports" + }] } }, { diff --git a/seeds/test/index.ts b/seeds/test/index.ts index 13c3b26..28c457e 100644 --- a/seeds/test/index.ts +++ b/seeds/test/index.ts @@ -13,7 +13,7 @@ export const clients = [ name: 'App One', secrets: '[]', redirect_urls: JSON.stringify([ 'https://example.com/one/callback' ]), - grants: JSON.stringify([ 'authorization_code', 'refresh_token', 'client_credentials' ]), + grants: JSON.stringify([ 'authorization_code', 'refresh_token', 'globalping_client_credentials' ]), }, { id: 'b2a50a7e-6dc5-423d-864e-173ea690992e', @@ -31,7 +31,7 @@ export const clients = [ name: 'Slack App', secrets: '["OSMOYY6tV16Kc0l+BB5ml4eKXFf4JaqARFMCdudKU98="]', redirect_urls: JSON.stringify([ 'https://example.com/three/callback' ]), - grants: JSON.stringify([ 'client_credentials' ]), + grants: JSON.stringify([ 'globalping_client_credentials', 'refresh_token' ]), }, ]; diff --git a/src/lib/http/middleware/authenticate.ts b/src/lib/http/middleware/authenticate.ts index bb90dd5..1e47dae 100644 --- a/src/lib/http/middleware/authenticate.ts +++ b/src/lib/http/middleware/authenticate.ts @@ -1,7 +1,7 @@ import config from 'config'; import { jwtVerify } from 'jose'; -import type { ExtendedMiddleware } from '../../../types.js'; +import { ExtendedMiddleware } from '../../../types.js'; const sessionConfig = config.get('server.session'); diff --git a/src/lib/http/middleware/cors.ts b/src/lib/http/middleware/cors.ts index 0d72238..df74008 100644 --- a/src/lib/http/middleware/cors.ts +++ b/src/lib/http/middleware/cors.ts @@ -1,4 +1,4 @@ -import type { Context, Next } from 'koa'; +import { Context, Next } from 'koa'; export const corsHandler = () => async (ctx: Context, next: Next) => { ctx.set('Access-Control-Allow-Origin', '*'); diff --git a/src/lib/http/middleware/default-json.ts b/src/lib/http/middleware/default-json.ts index c5c7bbc..dc7da6c 100644 --- a/src/lib/http/middleware/default-json.ts +++ b/src/lib/http/middleware/default-json.ts @@ -1,4 +1,4 @@ -import type { ExtendedMiddleware } from '../../../types.js'; +import { ExtendedMiddleware } from '../../../types.js'; import createHttpError from 'http-errors'; import _ from 'lodash'; diff --git a/src/lib/http/middleware/error-handler.ts b/src/lib/http/middleware/error-handler.ts index 75b57b3..b1d9d6c 100644 --- a/src/lib/http/middleware/error-handler.ts +++ b/src/lib/http/middleware/error-handler.ts @@ -1,7 +1,7 @@ import createHttpError from 'http-errors'; import newrelic from 'newrelic'; import { scopedLogger } from '../../logger.js'; -import type { ExtendedMiddleware } from '../../../types.js'; +import { ExtendedMiddleware } from '../../../types.js'; const logger = scopedLogger('error-handler-mw'); diff --git a/src/lib/http/middleware/validate.ts b/src/lib/http/middleware/validate.ts index 53f482e..a4ee1d0 100644 --- a/src/lib/http/middleware/validate.ts +++ b/src/lib/http/middleware/validate.ts @@ -1,5 +1,5 @@ -import type { Schema } from 'joi'; -import type { ExtendedMiddleware } from '../../../types.js'; +import { Schema } from 'joi'; +import { ExtendedMiddleware } from '../../../types.js'; import _ from 'lodash'; export const validate = (schema: Schema): ExtendedMiddleware => async (ctx, next) => { diff --git a/src/lib/redis/client.ts b/src/lib/redis/client.ts index 809c12f..363fde1 100644 --- a/src/lib/redis/client.ts +++ b/src/lib/redis/client.ts @@ -1,4 +1,4 @@ -import type { RedisClientOptions } from 'redis'; +import { RedisClientOptions } from 'redis'; import { createRedisClientInternal, RedisClient } from './shared.js'; let redis: RedisClient; diff --git a/src/lib/redis/shared.ts b/src/lib/redis/shared.ts index 4984615..7ae7439 100644 --- a/src/lib/redis/shared.ts +++ b/src/lib/redis/shared.ts @@ -1,5 +1,12 @@ import config from 'config'; -import { createClient, RedisClientOptions, RedisClientType, RedisDefaultModules, RedisFunctions, RedisScripts } from 'redis'; +import { + createClient, + RedisClientOptions, + RedisClientType, + RedisDefaultModules, + RedisFunctions, + RedisScripts, +} from 'redis'; import { scopedLogger } from '../logger.js'; const logger = scopedLogger('redis-client'); diff --git a/src/lib/server.ts b/src/lib/server.ts index 9f9c7c5..b40f62e 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -1,4 +1,4 @@ -import type { Server } from 'node:http'; +import { Server } from 'node:http'; export const createServer = async (): Promise => { const { getHttpServer } = await import('./http/server.js'); diff --git a/src/oauth/gp-client-credentials.ts b/src/oauth/gp-client-credentials.ts new file mode 100644 index 0000000..66e4fda --- /dev/null +++ b/src/oauth/gp-client-credentials.ts @@ -0,0 +1,72 @@ +import { + AbstractGrantType, + InvalidArgumentError, + InvalidGrantError, + Request, +} from '@node-oauth/oauth2-server'; +import { ClientCredentialsUser, ClientWithCredentials, GrantTypeOptions, User } from './types.js'; +import OAuthModel from './model.js'; + +export default class GPClientCredentials extends AbstractGrantType { + model: OAuthModel; + + constructor (options: GrantTypeOptions = {}) { + if (!options.model) { + throw new InvalidArgumentError('Missing parameter: `model`'); + } + + if (!options.model.getUserFromClient) { + throw new InvalidArgumentError('Invalid argument: model does not implement `getUserFromClient()`'); + } + + if (!options.model.saveToken) { + throw new InvalidArgumentError('Invalid argument: model does not implement `saveToken()`'); + } + + super(options); + this.model = options.model; + } + + async handle (request: Request, client: ClientWithCredentials) { + if (!request) { + throw new InvalidArgumentError('Missing parameter: `request`'); + } + + if (!client) { + throw new InvalidArgumentError('Missing parameter: `client`'); + } + + const scope = this.getScope(request); + const user = await this.getUserFromClient(client); + + return this.saveToken(user, client, scope); + } + + async getUserFromClient (client: ClientWithCredentials) { + const user = await this.model.getUserFromClient(client); + + if (!user) { + throw new InvalidGrantError('Invalid grant: user credentials are invalid'); + } + + return user; + } + + async saveToken (user: ClientCredentialsUser, client: ClientWithCredentials, requestedScope: string[]) { + const validatedScope = await this.validateScope(user, client, requestedScope) as string[]; + const accessToken = await this.generateAccessToken(client, user, validatedScope); + const refreshToken = await this.generateRefreshToken(client, user, validatedScope); + const accessTokenExpiresAt = this.getAccessTokenExpiresAt(); + const refreshTokenExpiresAt = this.getRefreshTokenExpiresAt(); + + const token = { + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + scope: validatedScope, + }; + + return this.model.saveToken(token, client, user as unknown as User); + } +} diff --git a/src/oauth/model.ts b/src/oauth/model.ts index e14cd45..6210f0a 100644 --- a/src/oauth/model.ts +++ b/src/oauth/model.ts @@ -5,17 +5,16 @@ import { base32 } from '@scure/base'; import _ from 'lodash'; import { - AuthorizationCode, - AuthorizationCodeModel, InvalidClientError, InvalidRequestError, + AuthorizationCode, + AuthorizationCodeModel, RefreshToken, RefreshTokenModel, Token as TokenWithClientUser, } from '@node-oauth/oauth2-server'; - -import type { Knex } from 'knex'; -import type { RedisClient } from '../lib/redis/shared.js'; +import { Knex } from 'knex'; +import { RedisClient } from '../lib/redis/shared.js'; import { AuthorizationCodeSaved, @@ -29,7 +28,7 @@ import { PublicAuthorizationCodeDetails, Token, User, - type Approval, + Approval, } from './types.js'; const getRandomBytes = promisify(randomBytes); diff --git a/src/oauth/route/approve.ts b/src/oauth/route/approve.ts index aa6e972..d6a052f 100644 --- a/src/oauth/route/approve.ts +++ b/src/oauth/route/approve.ts @@ -1,7 +1,7 @@ import { Response as OAuthResponse } from '@node-oauth/oauth2-server'; import { oAuthServer } from '../server.js'; -import type { ExtendedContext } from '../../types.js'; -import type { ApproveRequest } from '../types.js'; +import { ExtendedContext } from '../../types.js'; +import { ApproveRequest } from '../types.js'; export const approveGet = async (ctx: ExtendedContext): Promise => { const publicCodeId = ctx.params['publicCodeId']; diff --git a/src/oauth/route/authorize.ts b/src/oauth/route/authorize.ts index 524fa4f..f9acebf 100644 --- a/src/oauth/route/authorize.ts +++ b/src/oauth/route/authorize.ts @@ -1,7 +1,7 @@ import { Request as OAuthRequest, Response as OAuthResponse } from '@node-oauth/oauth2-server'; import { oAuthServer } from '../server.js'; -import type { ExtendedContext } from '../../types.js'; -import type { OAuthRouteOptions } from '../types.js'; +import { ExtendedContext } from '../../types.js'; +import { OAuthRouteOptions } from '../types.js'; type StateObject = { state?: string } | undefined; diff --git a/src/oauth/route/index.ts b/src/oauth/route/index.ts index 6505a37..77ea0d3 100644 --- a/src/oauth/route/index.ts +++ b/src/oauth/route/index.ts @@ -1,4 +1,4 @@ -import type Router from '@koa/router'; +import Router from '@koa/router'; import { corsHandler } from '../../lib/http/middleware/cors.js'; import { authenticate } from '../../lib/http/middleware/authenticate.js'; diff --git a/src/oauth/route/introspect.ts b/src/oauth/route/introspect.ts index 8a3fd36..8a4a58c 100644 --- a/src/oauth/route/introspect.ts +++ b/src/oauth/route/introspect.ts @@ -1,5 +1,5 @@ import { oAuthServer } from '../server.js'; -import type { ExtendedContext } from '../../types.js'; +import { ExtendedContext } from '../../types.js'; import { Request as OAuthRequest, Response as OAuthResponse } from '@node-oauth/oauth2-server'; export const introspectPost = () => { diff --git a/src/oauth/route/metadata.ts b/src/oauth/route/metadata.ts index 898fdc4..dbacefd 100644 --- a/src/oauth/route/metadata.ts +++ b/src/oauth/route/metadata.ts @@ -20,7 +20,7 @@ export const metadataGet = (options: OAuthRouteOptions) => { ], grant_types_supported: [ 'authorization_code', - 'client_credentials', + 'globalping_client_credentials', 'refresh_token', ], token_endpoint_auth_methods_supported: [ diff --git a/src/oauth/route/revoke.ts b/src/oauth/route/revoke.ts index 4f00618..d5f633b 100644 --- a/src/oauth/route/revoke.ts +++ b/src/oauth/route/revoke.ts @@ -1,5 +1,5 @@ import { oAuthServer } from '../server.js'; -import type { ExtendedContext } from '../../types.js'; +import { ExtendedContext } from '../../types.js'; import { Request as OAuthRequest, Response as OAuthResponse } from '@node-oauth/oauth2-server'; export const revokePost = () => { diff --git a/src/oauth/route/token.ts b/src/oauth/route/token.ts index 11ec59c..c38deca 100644 --- a/src/oauth/route/token.ts +++ b/src/oauth/route/token.ts @@ -1,6 +1,6 @@ import { Request as OAuthRequest, Response as OAuthResponse } from '@node-oauth/oauth2-server'; import { oAuthServer } from '../server.js'; -import type { ExtendedContext } from '../../types.js'; +import { ExtendedContext } from '../../types.js'; export const tokenPost = () => { return async (ctx: ExtendedContext): Promise => { diff --git a/src/oauth/server.ts b/src/oauth/server.ts index b134428..015c7bd 100644 --- a/src/oauth/server.ts +++ b/src/oauth/server.ts @@ -2,15 +2,15 @@ import config from 'config'; import { AccessDeniedError, + InvalidRequestError, + OAuthError, + UnauthorizedRequestError, AuthorizationCode, AuthorizeOptions, default as OAuthServer, - InvalidRequestError, - OAuthError, Request as OAuthRequest, Response as OAuthResponse, ServerOptions, - UnauthorizedRequestError, User, } from '@node-oauth/oauth2-server'; @@ -18,13 +18,14 @@ import { getRedisClient } from '../lib/redis/client.js'; import { client } from '../lib/sql/client.js'; import OAuthModel from './model.js'; -import type { Context } from 'koa'; +import { Context } from 'koa'; import { IntrospectionRequest, OAuthRouteOptions, PublicAuthorizationCodeDetails, RevocationRequest, } from './types.js'; +import GPClientCredentials from './gp-client-credentials.js'; const serverHost = config.get('server.host'); const dashHost = config.get('server.dashHost'); @@ -181,6 +182,9 @@ export const oAuthServerOptions = { docsHost, serverHost, directusHost, + extendedGrantTypes: { + globalping_client_credentials: GPClientCredentials, + }, }; export const oAuthServer = new ExtendedOAuthServer(oAuthServerOptions); diff --git a/src/oauth/types.ts b/src/oauth/types.ts index a7762c8..ae4fb8d 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -1,4 +1,5 @@ -import { AuthorizationCode, Client, Token as TokenWithClientUser } from '@node-oauth/oauth2-server'; +import { AuthorizationCode, Client, Token as TokenWithClientUser, TokenOptions } from '@node-oauth/oauth2-server'; +import OAuthModel from './model.js'; export type AuthorizationCodeToSave = Pick; export type AuthorizationCodeSaved = AuthorizationCodeToSave & { client: Pick, user: User, owner: { name: string | null, url: string | null }, rememberApproval: boolean, scopesToApprove: string[] }; @@ -6,6 +7,7 @@ export type PublicAuthorizationCodeDetails = Pick; export type ClientWithCredentials = Client & { name: string, secrets: string[], owner_name: string | null, owner_url: string | null, requestSecret: string | null }; export type User = { id: string; $state: string | null }; +export type GrantTypeOptions = TokenOptions & { model?: OAuthModel }; export type InternalToken = { id: number; diff --git a/src/types.d.ts b/src/types.d.ts index 9c35d2e..32d1d6d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,6 +1,6 @@ -import type Koa from 'koa'; -import type Router from '@koa/router'; -import type { AuthenticateState } from './lib/http/middleware/authenticate.js'; +import Koa from 'koa'; +import Router from '@koa/router'; +import { AuthenticateState } from './lib/http/middleware/authenticate.js'; export type CustomState = Koa.DefaultState & AuthenticateState; export type CustomContext = Koa.DefaultContext & Router.RouterParamContext; diff --git a/test/tests/integration/oauth.test.ts b/test/tests/integration/oauth.test.ts index 5a5e2d8..7d97fbd 100644 --- a/test/tests/integration/oauth.test.ts +++ b/test/tests/integration/oauth.test.ts @@ -88,6 +88,20 @@ describe('OAuth', () => { }); }; + const gpClientCredentialsTokenRequest = async (client: typeof clients[number], headers = {}, body = {}) => { + return requestAgent + .post(tokenEndpoint) + .set('Content-Type', 'application/x-www-form-urlencoded') + .set(headers) + .send({ + client_id: client.id, + client_secret: secrets.get(client), + grant_type: 'globalping_client_credentials', + scope: 'measurements', + ...body, + }); + }; + before(async () => { app = await getTestServer(); requestAgent = request(app); @@ -320,7 +334,7 @@ describe('OAuth', () => { it('should fail with unsupported grant_type', async () => { const res = await defaultTokenRequest(client2, {}, { - grant_type: 'client_credentials', + grant_type: 'globalping_client_credentials', }); expect(res.status).to.equal(400); @@ -381,7 +395,7 @@ describe('OAuth', () => { }); describe('Refresh Token Grant', () => { - it('should successfully exchange refresh token for new access token', async () => { + it('should successfully exchange authorization_code`s refresh token for new access token', async () => { // Get the initial token response to retrieve the refresh token const initialTokenResponse = await defaultTokenRequest(client1); expect(initialTokenResponse.status).to.equal(200); @@ -406,6 +420,31 @@ describe('OAuth', () => { expect(res.body).to.have.property('scope', 'measurements'); }); + it('should successfully exchange globalping_client_credentials`s refresh token for new access token', async () => { + // Get the initial token response to retrieve the refresh token + const initialTokenResponse = await gpClientCredentialsTokenRequest(client3); + expect(initialTokenResponse.status).to.equal(200); + const refreshToken = initialTokenResponse.body.refresh_token; + + // Use the refresh token to get a new access token + const res = await requestAgent + .post(tokenEndpoint) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ + client_id: client3.id, + client_secret: secrets.get(client3), + refresh_token: refreshToken, + grant_type: 'refresh_token', + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('access_token'); + expect(res.body).to.have.property('refresh_token'); + expect(res.body).to.have.property('expires_in'); + expect(res.body).to.have.property('token_type', 'Bearer'); + expect(res.body).to.have.property('scope', 'measurements'); + }); + it('should fail with invalid refresh token', async () => { const res = await requestAgent .post(tokenEndpoint) @@ -468,36 +507,22 @@ describe('OAuth', () => { }); }); - describe('Client Credentials Grant', () => { + describe('GP Client Credentials Grant', () => { it('should successfully authorize the client and return access token', async () => { - const res = await requestAgent - .post(tokenEndpoint) - .set('Content-Type', 'application/x-www-form-urlencoded') - .send({ - client_id: client3.id, - client_secret: secrets.get(client3), - grant_type: 'client_credentials', - scope: 'measurements', - }); + const res = await gpClientCredentialsTokenRequest(client3); expect(res.status).to.equal(200); expect(res.body).to.have.property('access_token'); - expect(res.body).to.not.have.property('refresh_token'); + expect(res.body).to.have.property('refresh_token'); expect(res.body).to.have.property('expires_in'); expect(res.body).to.have.property('token_type', 'Bearer'); expect(res.body).to.have.property('scope', 'measurements'); }); it('should fail with wrong client_secret', async () => { - const res = await requestAgent - .post(tokenEndpoint) - .set('Content-Type', 'application/x-www-form-urlencoded') - .send({ - client_id: client3.id, - client_secret: 'wrongSecretValue23456723456723456723456723456723', - grant_type: 'client_credentials', - scope: 'measurements', - }); + const res = await gpClientCredentialsTokenRequest(client3, {}, { + client_secret: 'wrongSecretValue23456723456723456723456723456723', + }); expect(res.status).to.equal(400); expect(res.body).to.have.property('error', 'invalid_client'); @@ -510,7 +535,7 @@ describe('OAuth', () => { .set('Content-Type', 'application/x-www-form-urlencoded') .send({ client_id: client3.id, - grant_type: 'client_credentials', + grant_type: 'globalping_client_credentials', scope: 'measurements', }); @@ -519,16 +544,8 @@ describe('OAuth', () => { expect(res.body).to.have.property('error_description').that.includes('cannot retrieve client credentials'); }); - it('should fail if client_credentials grant type is not specified for the client', async () => { - const res = await requestAgent - .post(tokenEndpoint) - .set('Content-Type', 'application/x-www-form-urlencoded') - .send({ - client_id: client2.id, - client_secret: secrets.get(client2), - grant_type: 'client_credentials', - scope: 'measurements', - }); + it('should fail if globalping_client_credentials grant type is not specified for the client', async () => { + const res = await gpClientCredentialsTokenRequest(client2); expect(res.status).to.equal(400); expect(res.body).to.have.property('error', 'unauthorized_client'); @@ -542,7 +559,7 @@ describe('OAuth', () => { .send({ client_id: client1.id, client_secret: 'randomSecretValue2345672345672345672345672345672', - grant_type: 'client_credentials', + grant_type: 'globalping_client_credentials', scope: 'measurements', });