Skip to content
This repository has been archived by the owner on Jul 25, 2024. It is now read-only.

Commit

Permalink
feat (all): lots of changes:
Browse files Browse the repository at this point in the history
- refresh token can be used to login again, without JWT
- allow user to choose whether to store JWT in db or not
- added refresh token expiration date
- added default expiration for JWT and refresh token (better safe than sorry)
- added redis JWT provider (it was missing completely)
  • Loading branch information
maxgalbu committed Nov 9, 2021
1 parent 291bb53 commit 825566a
Show file tree
Hide file tree
Showing 18 changed files with 1,352 additions and 313 deletions.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,25 @@ npm install adonis5-jwt
//Or, with yarn: yarn add adonis5-jwt
```

## Configure package

After the package has been installed, you have to configure it by running a command:

```js
node ace configure adonis5-jwt
```

This will ask a few questions and modify adonisjs files accordingly.
This will ask a few questions and modify adonisjs files accordingly.

During this configure, you will have to choose whether you want to store JWT in database or not.
The two solutions have advantages and disadvantages. Bear in mind that the default is NOT to store JWT in db, which is the recommended solution.

| Command | JWT in db | JWT not in db |
| --- | --- |
| refresh token stored in DB | :white_check_mark: | :white_check_mark: |
| full control on JWT expiration/revocation | :white_check_mark: | :x: |
| faster login that doesn't use DB | :x: | :white_check_mark: |
| logout needs refresh token | :x: | :white_check_mark: |

## Usage

Expand Down
68 changes: 49 additions & 19 deletions adonis-typings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,13 @@ declare module "@ioc:Adonis/Addons/Jwt" {
export type JWTLoginOptions = {
name?: string;
expiresIn?: number | string;
refreshTokenExpiresIn?: number | string;
payload?: JWTCustomPayloadData;
[key: string]: any;
};

export type DatabaseJWTTokenProviderConfig = DatabaseTokenProviderConfig & {
refreshTokenKey: string;
};
export type RedisJWTTokenProviderConfig = RedisTokenProviderConfig & {
refreshTokenKey: string;
export type JWTLogoutOptions = {
refreshToken: string;
};

/**
Expand Down Expand Up @@ -66,12 +64,26 @@ declare module "@ioc:Adonis/Addons/Jwt" {
*/
privateKey: string;

/**
* Whether this guard should store the JWT in the selected tokenProvider.
* If false, only the refresh token is stored.
*/
persistJwt: boolean;

/**
* Default JWT expire in human-readable time format (eg. 10h, 5d, 2m)
*/
jwtDefaultExpire: string;

/**
* Default refresh token expire in human-readable time format (eg. 10h, 5d, 2m)
*/
refreshTokenDefaultExpire: string;

/**
* Provider for managing tokens
*/
tokenProvider:
| DatabaseJWTTokenProviderConfig
| RedisJWTTokenProviderConfig;
tokenProvider: DatabaseTokenProviderConfig | RedisTokenProviderConfig;

/**
* User provider
Expand Down Expand Up @@ -140,22 +152,27 @@ declare module "@ioc:Adonis/Addons/Jwt" {
};
}

export interface JwtTokenProviderContract extends TokenProviderContract {
/**
* Delete token using the lookup id or the token value
*/
export interface JwtProviderContract extends TokenProviderContract {
destroyWithHash(token: string, type: string): Promise<void>;
readRefreshToken(userRefreshToken: string, tokenType: string): Promise<ProviderTokenContract | null>;
destroyRefreshToken(userRefreshToken: string, tokenType: string): Promise<void>;
}

export interface RefreshTokenProviderContract extends TokenProviderContract {
destroyWithHash(token: string, type: string): Promise<void>;
}

export interface JwtProviderTokenContract extends ProviderTokenContract {
refreshToken: string;
refreshTokenExpiresAt: DateTime;
}

/**
* Shape of the JWT guard
*/
export interface JWTGuardContract<
Provider extends keyof ProvidersList,
Name extends keyof GuardsList
> extends GuardContract<Provider, Name> {
token?: ProviderTokenContract;
tokenProvider: JwtTokenProviderContract;
export interface JWTGuardContract<Provider extends keyof ProvidersList, Name extends keyof GuardsList>
extends GuardContract<Provider, Name> {
tokenProvider: JwtProviderContract | RefreshTokenProviderContract;
payload?: JWTCustomPayloadData;

/**
Expand All @@ -175,6 +192,14 @@ declare module "@ioc:Adonis/Addons/Jwt" {
options?: JWTLoginOptions
): Promise<JWTTokenContract<GetProviderRealUser<Provider>>>;

/**
* Login a user using refresh token
*/
loginViaRefreshToken(
refreshToken: string,
options?: JWTLoginOptions
): Promise<JWTTokenContract<GetProviderRealUser<Provider>>>;

/**
* Generate token for a user without any verification
*/
Expand All @@ -186,7 +211,12 @@ declare module "@ioc:Adonis/Addons/Jwt" {
/**
* Alias for logout
*/
revoke(): Promise<void>;
logout(options?: JWTLogoutOptions): Promise<void>;

/**
* Alias for logout
*/
revoke(options?: JWTLogoutOptions): Promise<void>;

/**
* Login a user using their id
Expand Down
96 changes: 79 additions & 17 deletions instructions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { join } from "path";
import ms from "ms";
import { generateKeyPair } from "crypto";
import * as sinkStatic from "@adonisjs/sink";
import { string } from "@poppinss/utils/build/helpers";
import { ApplicationContract } from "@ioc:Adonis/Core/Application";
import { IndentationText, NewLineKind, Project, PropertyAssignment, SyntaxKind, Writers } from "ts-morph";
import {
IndentationText,
NewLineKind,
Project,
PropertyAssignment,
SyntaxKind,
Writers,
} from "ts-morph";
import { parse as parseEditorConfig } from "editorconfig";

type InstructionsState = {
persistJwt: boolean;
jwtDefaultExpire: string;
refreshTokenDefaultExpire: string;

usersTableName?: string;
usersModelName?: string;
usersModelNamespace?: string;
Expand All @@ -22,10 +34,10 @@ type InstructionsState = {

type DefinedProviders = {
[name: string]: {
type: "lucid" | "database",
model?: string,
}
}
type: "lucid" | "database";
model?: string;
};
};

/**
* Prompt choices for the tokens provider selection
Expand Down Expand Up @@ -55,7 +67,7 @@ function getStub(...relativePaths: string[]) {
*
* @returns
*/
async function getIntendationConfigForTsMorph(projectRoot :string) {
async function getIntendationConfigForTsMorph(projectRoot: string) {
const indentConfig = await parseEditorConfig(projectRoot + "/.editorconfig");

let indentationText;
Expand Down Expand Up @@ -101,7 +113,12 @@ function makeTokensMigration(
const migrationsDirectory = app.directoriesMap.get("migrations") || "database";
const migrationPath = join(migrationsDirectory, `${Date.now()}_${state.tokensTableName}.ts`);

const template = new sink.files.MustacheFile(projectRoot, migrationPath, getStub("migrations/jwt_tokens.txt"));
let templateFile = "migrations/jwt_tokens.txt";
if (!state.persistJwt) {
templateFile = "migrations/jwt_refresh_tokens.txt";
}

const template = new sink.files.MustacheFile(projectRoot, migrationPath, getStub(templateFile));
if (template.exists()) {
sink.logger.action("create").skipped(`${migrationPath} file already exists`);
return;
Expand All @@ -128,7 +145,7 @@ async function getDefinedProviders(projectRoot: string, app: ApplicationContract
//Doesn't work without single quotes wrapping the module name
const authModule = authContractFile?.getModuleOrThrow("'@ioc:Adonis/Addons/Auth'");

const definedProviders :DefinedProviders = {};
const definedProviders: DefinedProviders = {};

const providersInterface = authModule.getInterfaceOrThrow("ProvidersList");
const userProviders = providersInterface.getProperties();
Expand Down Expand Up @@ -163,7 +180,9 @@ async function getDefinedProviders(projectRoot: string, app: ApplicationContract
}

if (!Object.keys(definedProviders).length) {
throw new Error("No provider implementation found in ProvidersList. Maybe you didn't configure @adonisjs/auth first?");
throw new Error(
"No provider implementation found in ProvidersList. Maybe you didn't configure @adonisjs/auth first?"
);
}

return definedProviders;
Expand Down Expand Up @@ -270,7 +289,6 @@ async function editConfig(
driver: "'database'",
table: "'jwt_tokens'",
foreignKey: "'user_id'",
refreshTokenKey: "'refresh_token'",
});
}

Expand All @@ -290,7 +308,7 @@ async function editConfig(
model: `() => import('${state.usersModelNamespace}')`,
});
} else {
throw new Error(`Invalid state.provider: ${state.provider}`)
throw new Error(`Invalid state.provider: ${state.provider}`);
}

//Instantiate ts-morph
Expand Down Expand Up @@ -322,6 +340,9 @@ async function editConfig(
driver: '"jwt"',
publicKey: `Env.get('JWT_PUBLIC_KEY', '').replace(/\\\\n/g, '\\n')`,
privateKey: `Env.get('JWT_PRIVATE_KEY', '').replace(/\\\\n/g, '\\n')`,
persistJwt: `${state.persistJwt ? "true" : "false"}`,
jwtDefaultExpire: `'${state.jwtDefaultExpire}'`,
refreshTokenDefaultExpire: `'${state.refreshTokenDefaultExpire}'`,
tokenProvider: tokenProvider,
provider: provider,
}),
Expand Down Expand Up @@ -373,7 +394,10 @@ async function makeKeys(
/**
* Prompts user to select the provider
*/
async function getProvider(sink: typeof sinkStatic, definedProviders: DefinedProviders): Promise<"lucid" | "database" | string> {
async function getProvider(
sink: typeof sinkStatic,
definedProviders: DefinedProviders
): Promise<"lucid" | "database" | string> {
let choices = {
lucid: {
name: "lucid",
Expand All @@ -388,10 +412,12 @@ async function getProvider(sink: typeof sinkStatic, definedProviders: DefinedPro
};

for (const providerName in definedProviders) {
const {type: definedProviderType} = definedProviders[providerName];
const { type: definedProviderType } = definedProviders[providerName];
if (choices[definedProviderType]) {
choices[definedProviderType].name = providerName;
choices[definedProviderType].message = `Already configured ${string.capitalCase(definedProviderType)} provider (${providerName})`;
choices[definedProviderType].message = `Already configured ${string.capitalCase(
definedProviderType
)} provider (${providerName})`;
}
}

Expand Down Expand Up @@ -445,16 +471,47 @@ async function getMigrationConsent(sink: typeof sinkStatic, tableName: string):
}

function getModelNamespace(app: ApplicationContract, usersModelName) {
return `${app.namespacesMap.get("models") || "App/Models"}/${string.capitalCase(
usersModelName
)}`;
return `${app.namespacesMap.get("models") || "App/Models"}/${string.capitalCase(usersModelName)}`;
}

async function getPersistJwt(sink: typeof sinkStatic): Promise<boolean> {
return sink.getPrompt().confirm(`Do you want to persist JWT in database/redis (please read README.md beforehand)?`);
}

async function getJwtDefaultExpire(sink: typeof sinkStatic, state: InstructionsState): Promise<string> {
return sink.getPrompt().ask("Enter the default expire time for the JWT (10h = 10 hours, 5d = 5 days, etc)", {
default: state.jwtDefaultExpire,
validate(value) {
if (!value.match(/^[0-9]+[a-z]+$/)) {
return false;
}
return !!ms(value);
},
});
}

async function getRefreshTokenDefaultExpire(sink: typeof sinkStatic, state: InstructionsState): Promise<string> {
return sink
.getPrompt()
.ask("Enter the default expire time for the refresh token (10h = 10 hours, 5d = 5 days, etc)", {
default: state.refreshTokenDefaultExpire,
validate(value) {
if (!value.match(/^[0-9]+[a-z]+$/)) {
return false;
}
return !!ms(value);
},
});
}

/**
* Instructions to be executed when setting up the package.
*/
export default async function instructions(projectRoot: string, app: ApplicationContract, sink: typeof sinkStatic) {
const state: InstructionsState = {
persistJwt: false,
jwtDefaultExpire: "10m",
refreshTokenDefaultExpire: "10d",
tokensTableName: "jwt_tokens",
tokensSchemaName: "JwtTokens",
provider: "lucid",
Expand Down Expand Up @@ -497,12 +554,17 @@ export default async function instructions(projectRoot: string, app: Application
}
}

state.persistJwt = await getPersistJwt(sink);

let tokensMigrationConsent = false;
state.tokensProvider = await getTokensProvider(sink);
if (state.tokensProvider === "database") {
tokensMigrationConsent = await getMigrationConsent(sink, state.tokensTableName);
}

state.jwtDefaultExpire = await getJwtDefaultExpire(sink, state);
state.refreshTokenDefaultExpire = await getRefreshTokenDefaultExpire(sink, state);

await makeKeys(projectRoot, app, sink, state);

/**
Expand Down
Loading

0 comments on commit 825566a

Please sign in to comment.