Skip to content

Commit

Permalink
feat: globalping_client_credentials grant (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexey-yarmosh authored Oct 25, 2024
1 parent ab08c59 commit 3c14f9c
Show file tree
Hide file tree
Showing 23 changed files with 174 additions and 68 deletions.
5 changes: 5 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}]
}
},
{
Expand Down
4 changes: 2 additions & 2 deletions seeds/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' ]),
},
];

Expand Down
2 changes: 1 addition & 1 deletion src/lib/http/middleware/authenticate.ts
Original file line number Diff line number Diff line change
@@ -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<AuthenticateOptions['session']>('server.session');

Expand Down
2 changes: 1 addition & 1 deletion src/lib/http/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -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', '*');
Expand Down
2 changes: 1 addition & 1 deletion src/lib/http/middleware/default-json.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ExtendedMiddleware } from '../../../types.js';
import { ExtendedMiddleware } from '../../../types.js';
import createHttpError from 'http-errors';
import _ from 'lodash';

Expand Down
2 changes: 1 addition & 1 deletion src/lib/http/middleware/error-handler.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down
4 changes: 2 additions & 2 deletions src/lib/http/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/redis/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RedisClientOptions } from 'redis';
import { RedisClientOptions } from 'redis';
import { createRedisClientInternal, RedisClient } from './shared.js';

let redis: RedisClient;
Expand Down
9 changes: 8 additions & 1 deletion src/lib/redis/shared.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/lib/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Server } from 'node:http';
import { Server } from 'node:http';

export const createServer = async (): Promise<Server> => {
const { getHttpServer } = await import('./http/server.js');
Expand Down
72 changes: 72 additions & 0 deletions src/oauth/gp-client-credentials.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 5 additions & 6 deletions src/oauth/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,7 +28,7 @@ import {
PublicAuthorizationCodeDetails,
Token,
User,
type Approval,
Approval,
} from './types.js';

const getRandomBytes = promisify(randomBytes);
Expand Down
4 changes: 2 additions & 2 deletions src/oauth/route/approve.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
const publicCodeId = ctx.params['publicCodeId'];
Expand Down
4 changes: 2 additions & 2 deletions src/oauth/route/authorize.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/oauth/route/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/route/introspect.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/route/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/route/revoke.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/route/token.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
Expand Down
12 changes: 8 additions & 4 deletions src/oauth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,30 @@ 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';

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<string>('server.host');
const dashHost = config.get<string>('server.dashHost');
Expand Down Expand Up @@ -181,6 +182,9 @@ export const oAuthServerOptions = {
docsHost,
serverHost,
directusHost,
extendedGrantTypes: {
globalping_client_credentials: GPClientCredentials,
},
};

export const oAuthServer = new ExtendedOAuthServer(oAuthServerOptions);
4 changes: 3 additions & 1 deletion src/oauth/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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<AuthorizationCode, 'authorizationCode' | 'expiresAt' | 'redirectUri' | 'scope' | 'codeChallenge' | 'codeChallengeMethod'>;
export type AuthorizationCodeSaved = AuthorizationCodeToSave & { client: Pick<Client, 'id' | 'name'>, user: User, owner: { name: string | null, url: string | null }, rememberApproval: boolean, scopesToApprove: string[] };
export type PublicAuthorizationCodeDetails = Pick<AuthorizationCodeSaved, 'scope' | 'client'>;
export type Token = Pick<TokenWithClientUser, 'accessToken' | 'accessTokenExpiresAt' | 'refreshToken' | 'refreshTokenExpiresAt' | 'scope'>;
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;
Expand Down
6 changes: 3 additions & 3 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading

0 comments on commit 3c14f9c

Please sign in to comment.