Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Personal Access Tokens #365

Merged
merged 8 commits into from
Apr 19, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM node:16-alpine AS builder
WORKDIR /app
RUN npm i -g pnpm
RUN npm i -g pnpm@7.30.2
COPY package.json pnpm-lock.yaml tsconfig.json tsconfig.build.json ./
RUN pnpm install --frozen-lockfile
COPY src/ ./src/
Expand All @@ -12,7 +12,7 @@ ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
ENV AUTH_PORT 4000
WORKDIR /app
RUN npm i -g pnpm
RUN npm i -g pnpm@7.30.2
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod && pnpm store prune
COPY migrations/ ./migrations/
Expand Down
5 changes: 5 additions & 0 deletions migrations/00012_add_refresh_token_metadata.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE auth.refresh_tokens
ADD COLUMN metadata JSONB;
COMMIT;

5 changes: 5 additions & 0 deletions migrations/00013_add_refresh_token_type.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BEGIN;
dbarrosop marked this conversation as resolved.
Show resolved Hide resolved
CREATE TYPE refresh_token_type AS ENUM ('regular', 'pat');
ALTER TABLE auth.refresh_tokens
ADD COLUMN type refresh_token_type NOT NULL DEFAULT 'regular';
END;
6 changes: 5 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Response, Request, NextFunction } from 'express';
import { NextFunction, Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';

import { ENV, generateRedirectUrl } from './utils';
Expand Down Expand Up @@ -59,6 +59,10 @@ export const ERRORS = asErrors({
status: StatusCodes.BAD_REQUEST,
message: 'The request payload is incorrect',
},
'invalid-expiry-date': {
status: StatusCodes.BAD_REQUEST,
message: 'The expiry date must be greater than the current date',
},
'disabled-mfa-totp': {
status: StatusCodes.BAD_REQUEST,
message: 'MFA TOTP is not enabled for this user',
Expand Down
19 changes: 10 additions & 9 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { sendError } from '@/errors';
import * as express from 'express';
import nocache from 'nocache';
import { ReasonPhrases } from 'http-status-codes';

import { sendError } from '@/errors';
import { signUpRouter } from './signup';
import { signInRouter } from './signin';
import { userRouter } from './user';
import nocache from 'nocache';
import env from './env';
import { mfaRouter } from './mfa';
import { tokenRouter } from './token';
import { oauthProviders } from './oauth';
import { patRouter } from './pat';
import { signInRouter } from './signin';
import { signOutRouter } from './signout';
import env from './env';
import { signUpRouter } from './signup';
import { tokenRouter } from './token';
import { userRouter } from './user';
import { verifyRouter } from './verify';
import { oauthProviders } from './oauth';

const router = express.Router();
router.use(nocache());
Expand Down Expand Up @@ -41,6 +41,7 @@ router.use(signOutRouter);
router.use(userRouter);
router.use(mfaRouter);
router.use(tokenRouter);
router.use(patRouter);
router.use(verifyRouter);

// admin
Expand Down
20 changes: 20 additions & 0 deletions src/routes/pat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asyncWrapper as aw } from '@/utils';
import { bodyValidator } from '@/validation';
import { Router } from 'express';
import { createPATHandler, createPATSchema } from './pat';

const router = Router();

/**
* POST /pat
* @summary Create a Personal Access Token (PAT)
* @param {CreatePATSchema} request.body.required
* @return {SessionModel} 200 - User successfully authenticated - application/json
* @return {InvalidRequestError} 400 - The payload is invalid - application/json
* @return {UnauthorizedError} 401 - Unauthenticated user or invalid token - application/json
* @tags General
*/
router.post('/pat', bodyValidator(createPATSchema), aw(createPATHandler));

const patRouter = router;
export { patRouter };
60 changes: 60 additions & 0 deletions src/routes/pat/pat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { sendError } from '@/errors';
import { getUser, gqlSdk } from '@/utils';
import { RequestHandler } from 'express';
import Joi from 'joi';
import { v4 as uuidv4 } from 'uuid';

export const createPATSchema = Joi.object({
expiresAt: Joi.date().required(),
metadata: Joi.object(),
dbarrosop marked this conversation as resolved.
Show resolved Hide resolved
}).meta({ className: 'CreatePATSchema' });

export const createPATHandler: RequestHandler<
{},
{},
{ metadata: object; expiresAt: Date }
> = async (req, res) => {
if (!req.auth) {
return sendError(res, 'unauthenticated-user');
}

const { userId } = req.auth as RequestAuth;

const user = await getUser({ userId });

if (!user) {
return sendError(res, 'invalid-refresh-token');
szilarddoro marked this conversation as resolved.
Show resolved Hide resolved
}

const { metadata, expiresAt } = req.body;

// Note: Joi wouldn't work here because we need to compare the date to the
// date of the request, not the date when the schema was created
// 7 days
nunopato marked this conversation as resolved.
Show resolved Hide resolved
if (
new Date(expiresAt).setHours(0, 0, 0, 0) <
new Date().setHours(0, 0, 0, 0) + 7 * 24 * 60 * 60 * 1000
) {
return sendError(res, 'invalid-expiry-date', {
customMessage: 'The expiry date must be at least 7 days from now',
});
}

const { id } = user;

const personalAccessToken = uuidv4();

await gqlSdk.insertRefreshToken({
refreshToken: {
userId: id,
refreshToken: personalAccessToken,
expiresAt: new Date(expiresAt),
metadata,
type: 'pat',
},
});

return res.send({
personalAccessToken,
});
};
22 changes: 19 additions & 3 deletions src/routes/signin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@ import { Router } from 'express';
import { asyncWrapper as aw } from '@/utils';
import { bodyValidator } from '@/validation';

import { signInAnonymousHandler, signInAnonymousSchema } from './anonymous';
import {
signInEmailPasswordHandler,
signInEmailPasswordSchema,
} from './email-password';
import { signInAnonymousHandler, signInAnonymousSchema } from './anonymous';
import { signInOtpHandler, signInOtpSchema } from './passwordless/sms/otp';
import { signInMfaTotpHandler, signInMfaTotpSchema } from './mfa';
import {
signInPasswordlessEmailHandler,
signInPasswordlessEmailSchema,
signInPasswordlessSmsHandler,
signInPasswordlessSmsSchema,
} from './passwordless';
import { signInMfaTotpHandler, signInMfaTotpSchema } from './mfa';
import { signInOtpHandler, signInOtpSchema } from './passwordless/sms/otp';
import { signInPATHandler, signInPATSchema } from './pat';
import {
signInVerifyWebauthnHandler,
signInVerifyWebauthnSchema,
Expand Down Expand Up @@ -149,6 +150,21 @@ router.post(
aw(signInMfaTotpHandler)
);

/**
* POST /signin/pat
* @summary Sign in with a Personal Access Token (PAT)
* @param {SignInPATSchema} request.body.required
* @return {SessionPayload} 200 - User successfully authenticated - application/json
* @return {InvalidRequestError} 400 - The payload is invalid - application/json
* @return {DisabledEndpointError} 404 - The feature is not activated - application/json
* @tags Authentication
*/
router.post(
'/signin/pat',
bodyValidator(signInPATSchema),
aw(signInPATHandler)
);

// TODO: Implement:
// router.post(
// '/signin/mfa/sms',
Expand Down
37 changes: 37 additions & 0 deletions src/routes/signin/pat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { sendError } from '@/errors';
import { ENV, createHasuraAccessToken, getUserByRefreshToken } from '@/utils';
import { personalAccessToken } from '@/validation';
import { RequestHandler } from 'express';
import Joi from 'joi';

export const signInPATSchema = Joi.object({
personalAccessToken,
}).meta({ className: 'SignInPATSchema' });

export const signInPATHandler: RequestHandler<
{},
{},
{ personalAccessToken: string }
> = async (req, res) => {
const user = await getUserByRefreshToken(req.body.personalAccessToken);
szilarddoro marked this conversation as resolved.
Show resolved Hide resolved

if (!user) {
return sendError(res, 'invalid-refresh-token');
}

if (user.disabled) {
return sendError(res, 'disabled-user');
}

const accessToken = await createHasuraAccessToken(user);

return res.send({
mfa: null,
session: {
accessToken,
accessTokenExpiresIn: ENV.AUTH_ACCESS_TOKEN_EXPIRES_IN,
refreshToken: null,
user,
},
});
};
4 changes: 2 additions & 2 deletions src/routes/token/token.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { RequestHandler } from 'express';
import { sendError } from '@/errors';
import {
getNewOrUpdateCurrentSession,
getUserByRefreshToken,
gqlSdk,
} from '@/utils';
import { sendError } from '@/errors';
import { Joi, refreshToken } from '@/validation';
import { RequestHandler } from 'express';

export const tokenSchema = Joi.object({
refreshToken,
Expand Down
Loading