├── .npmrc ├── .prettierignore ├── .gitattributes ├── stubs ├── main.ts ├── temp.stub ├── key.stub ├── credentials.stub └── start.stub ├── index.ts ├── tsconfig.json ├── instructions.md ├── .gitignore ├── types └── config.ts ├── .editorconfig ├── .github ├── workflows │ ├── publish.yml │ └── test.yml ├── ISSUE_TEMPLATE.md ├── stale.yml ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md └── COMMIT_CONVENTION.md ├── tsnode.esm.js ├── src ├── filesystem.ts ├── validator.ts ├── errors.ts ├── parser.ts ├── credentials.ts └── vault.ts ├── LICENSE.md ├── bin └── test.ts ├── tests ├── validator.spec.ts ├── filesystem.spec.ts ├── credentials_create.spec.ts ├── parser.spec.ts ├── credentials.spec.ts ├── credentials_edit.spec.ts └── vault.spec.ts ├── commands ├── сreate.ts └── edit.ts ├── configure.ts ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | coverage 4 | *.html -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | import { getDirname } from '@poppinss/utils' 2 | 3 | export const stubsRoot = getDirname(import.meta.url) 4 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { Credentials } from './src/credentials.js' 2 | export { configure } from './configure.js' 3 | export * as errors from './src/errors.js' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /stubs/temp.stub: -------------------------------------------------------------------------------- 1 | {{#var passedValue = value}} 2 | {{#var fileName = `${randomId}.${format}`}} 3 | {{{ 4 | exports({ 5 | to: app.tmpPath(fileName) 6 | }) 7 | }}} 8 | {{ passedValue }} -------------------------------------------------------------------------------- /stubs/key.stub: -------------------------------------------------------------------------------- 1 | {{#var passedValue = value}} 2 | {{#var fileName = `${env}.key`}} 3 | {{{ 4 | exports({ 5 | to: app.makePath('resources/credentials', fileName) 6 | }) 7 | }}} 8 | {{{ passedValue }}} -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | ## Final check 2 | 3 | Make sure to check your `.gitignore` file for a `*.key` exclusion rule, as this will not allow to commit your key files. 4 | You should not commit them, **JUST DON'T**! 5 | -------------------------------------------------------------------------------- /stubs/credentials.stub: -------------------------------------------------------------------------------- 1 | {{#var passedValue = value}} 2 | {{#var fileName = `${env}.credentials`}} 3 | {{{ 4 | exports({ 5 | to: app.makePath('resources/credentials', fileName) 6 | }) 7 | }}} 8 | {{{ passedValue }}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json -------------------------------------------------------------------------------- /types/config.ts: -------------------------------------------------------------------------------- 1 | export interface CredentialsConfig { 2 | path?: string 3 | environment?: string 4 | populateEnvironment?: boolean 5 | } 6 | 7 | export interface VaultConfig { 8 | path?: string 9 | environment?: string 10 | } 11 | -------------------------------------------------------------------------------- /stubs/start.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ 3 | to: app.startPath('credentials.ts') 4 | }) 5 | }}} 6 | import { Credentials } from '@bitkidd/adonisjs-credentials' 7 | 8 | export default await Credentials.create({ 9 | HELLO: Credentials.schema.string(), 10 | }) 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 20.x 16 | 17 | - run: npm install 18 | - run: npm run lint 19 | - run: npm run test 20 | - uses: JS-DevTools/npm-publish@v1 21 | with: 22 | token: ${{ secrets.NPM_TOKEN }} 23 | access: public 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | linux: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: 11 | - 18.x 12 | - 20.x 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Install 20 | run: npm install 21 | - name: Run tests 22 | run: npm test 23 | -------------------------------------------------------------------------------- /tsnode.esm.js: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | TS-Node ESM hook 4 | |-------------------------------------------------------------------------- 5 | | 6 | | Importing this file before any other file will allow you to run TypeScript 7 | | code directly using TS-Node + SWC. For example 8 | | 9 | | node --import="./tsnode.esm.js" bin/test.ts 10 | | node --import="./tsnode.esm.js" index.ts 11 | | 12 | | 13 | | Why not use "--loader=ts-node/esm"? 14 | | Because, loaders have been deprecated. 15 | */ 16 | 17 | import { register } from 'node:module' 18 | register('ts-node/esm', import.meta.url) 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Prerequisites 4 | 5 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 6 | 7 | - Ensure the issue isn't already reported. 8 | - Ensure you are reporting the bug in the correct repo. 9 | 10 | *Delete the above section and the instructions in the sections below before submitting* 11 | 12 | ## Description 13 | 14 | If this is a feature request, explain why it should be added. Specific use-cases are best. 15 | 16 | For bug reports, please provide as much *relevant* info as possible. 17 | 18 | ## Package version 19 | 20 | 21 | ## Error Message & Stack Trace 22 | 23 | ## Relevant Information 24 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - "Type: Security" 10 | 11 | # Label to use when marking an issue as stale 12 | staleLabel: "Status: Abandoned" 13 | 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /src/filesystem.ts: -------------------------------------------------------------------------------- 1 | import fspath from 'node:path' 2 | import fs from 'node:fs/promises' 3 | 4 | export class FileSystem { 5 | async exists({ path }: { path: string }) { 6 | try { 7 | await fs.stat(path) 8 | return true 9 | } catch { 10 | return false 11 | } 12 | } 13 | 14 | async read({ path }: { path: string }) { 15 | const content = await fs.readFile(path, { encoding: 'utf-8' }) 16 | return content 17 | } 18 | 19 | async write({ path, data }: { path: string; data: any }) { 20 | const dirname = fspath.dirname(path) 21 | const dirnameExists = await this.exists({ path: dirname }) 22 | 23 | if (!dirnameExists) { 24 | await fs.mkdir(dirname, { recursive: true }) 25 | } 26 | 27 | await fs.writeFile(path, data, { encoding: 'utf-8' }) 28 | } 29 | 30 | async erase({ path }: { path: string }) { 31 | await fs.unlink(path) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love pull requests. And following this guidelines will make your pull request easier to merge 4 | 5 | ## Prerequisites 6 | 7 | - Install [EditorConfig](http://editorconfig.org/) plugin for your code editor to make sure it uses correct settings. 8 | - Fork the repository and clone your fork. 9 | - Install dependencies: `npm install`. 10 | 11 | ## Coding style 12 | 13 | We make use of [standard](https://standardjs.com/) to lint our code. Standard does not need a config file and comes with set of non-configurable rules. 14 | 15 | ## Development work-flow 16 | 17 | Always make sure to lint and test your code before pushing it to the GitHub. 18 | 19 | ```bash 20 | npm test 21 | ``` 22 | 23 | Just lint the code 24 | 25 | ```bash 26 | npm run lint 27 | ``` 28 | 29 | **Make sure you add sufficient tests for the change**. 30 | 31 | ## Other notes 32 | 33 | - Do not change version number inside the `package.json` file. 34 | - Do not update `CHANGELOG.md` file. 35 | 36 | ## Need help? 37 | 38 | Feel free to ask. 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2022 Chirill Ceban, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { fileSystem } from '@japa/file-system' 3 | import { expectTypeOf } from '@japa/expect-type' 4 | import { processCLIArgs, configure, run } from '@japa/runner' 5 | 6 | /* 7 | |-------------------------------------------------------------------------- 8 | | Configure tests 9 | |-------------------------------------------------------------------------- 10 | | 11 | | The configure method accepts the configuration to configure the Japa 12 | | tests runner. 13 | | 14 | | The first method call "processCliArgs" process the command line arguments 15 | | and turns them into a config object. Using this method is not mandatory. 16 | | 17 | | Please consult japa.dev/runner-config for the config docs. 18 | */ 19 | processCLIArgs(process.argv.slice(2)) 20 | configure({ 21 | files: ['tests/**/*.spec.ts'], 22 | plugins: [assert(), expectTypeOf(), fileSystem()], 23 | }) 24 | 25 | /* 26 | |-------------------------------------------------------------------------- 27 | | Run tests 28 | |-------------------------------------------------------------------------- 29 | | 30 | | The following "run" method is required to execute all the tests. 31 | | 32 | */ 33 | run() 34 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | import { ValidateFn } from '@poppinss/validator-lite' 2 | 3 | import { E_CREDENTIALS_INVALID } from './errors.js' 4 | 5 | export class Validator }> { 6 | #schema: Schema 7 | 8 | constructor(schema: Schema) { 9 | this.#schema = schema 10 | } 11 | 12 | validate({ data }: { data: { [K: string]: string | undefined } }): { 13 | [K in keyof Schema]: ReturnType 14 | } { 15 | const help: string[] = [] 16 | 17 | const validated = Object.keys(this.#schema).reduce( 18 | (result, key) => { 19 | const value = process.env[key] || data[key] 20 | 21 | try { 22 | result[key] = this.#schema[key](key, value) as any 23 | } catch (error) { 24 | const updatedMessage = error.message.replace('environment', 'credentials') 25 | help.push(`- ${updatedMessage}`) 26 | } 27 | return result 28 | }, 29 | { ...data } 30 | ) as { [K in keyof Schema]: ReturnType } 31 | 32 | if (help.length) { 33 | const error = new E_CREDENTIALS_INVALID() 34 | error.help = help.join('\n') 35 | 36 | throw error 37 | } 38 | 39 | return validated 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '@poppinss/utils' 2 | 3 | export const E_CREDENTIALS_INVALID = class CredentialsException extends Exception { 4 | static message = 'Credentials validation failed for one or more items' 5 | static code = 'E_CREDENTIALS_INVALID' 6 | help: string = '' 7 | } 8 | 9 | export const E_CREDENTIALS_UNKNOWN_VERSION = class CredentialsException extends Exception { 10 | static message = 'Credentials file or key version is unknown' 11 | static code = 'E_CREDENTIALS_UNKNOWN_VERSION' 12 | help: string = '' 13 | } 14 | 15 | export const E_CREDENTIALS_INVALID_FORMAT = class CredentialsException extends Exception { 16 | static message = 'Credentials file format is invalid, should be YAML or JSON' 17 | static code = 'E_CREDENTIALS_INVALID_FORMAT' 18 | help: string = '' 19 | } 20 | 21 | export const E_CREDENTIALS_CANNOT_DECRYPT = class CredentialsException extends Exception { 22 | static message = 'Credentials cannot be decrypted, wrong key of credential file is corrupted' 23 | static code = 'E_CREDENTIALS_CANNOT_DECRYPT' 24 | help: string = '' 25 | } 26 | 27 | export const E_CREDENTIALS_MISSING_KEY = class CredentialsException extends Exception { 28 | static message = 29 | 'Credentials key is missing, please set it in a file or in APP_CREDENTIALS_KEY environment variable' 30 | static code = 'E_CREDENTIALS_MISSING_KEY' 31 | help: string = '' 32 | } 33 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Proposed changes 4 | 5 | Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. 6 | 7 | ## Types of changes 8 | 9 | What types of changes does your code introduce? 10 | 11 | _Put an `x` in the boxes that apply_ 12 | 13 | - [ ] Bugfix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | 17 | ## Checklist 18 | 19 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 20 | 21 | - [ ] I have read the [CONTRIBUTING](https://github.com/bitkidd/adonis-credentials/blob/master/.github/CONTRIBUTING.md) doc 22 | - [ ] Lint and unit tests pass locally with my changes 23 | - [ ] I have added tests that prove my fix is effective or that my feature works. 24 | - [ ] I have added necessary documentation (if appropriate) 25 | 26 | ## Further comments 27 | 28 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 29 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'yaml' 2 | import { E_CREDENTIALS_INVALID_FORMAT } from './errors.js' 3 | 4 | export class Parser { 5 | #prepare({ data }: { data: string }) { 6 | let format = 'unknown' 7 | let parsedData = null 8 | 9 | try { 10 | parsedData = JSON.parse(data) 11 | format = 'json' 12 | 13 | return { data: parsedData, format } 14 | } catch { 15 | try { 16 | parsedData = yaml.parse(data) 17 | format = 'yaml' 18 | 19 | return { data: parsedData, format } 20 | } catch { 21 | throw new E_CREDENTIALS_INVALID_FORMAT() 22 | } 23 | } 24 | } 25 | 26 | #transform({ data, prefix }: { data: any; prefix?: string }) { 27 | let result: Record = {} 28 | 29 | for (const [key, value] of Object.entries(data)) { 30 | const currentKey = prefix ? `${prefix}_${key}` : key 31 | 32 | if (value && typeof value === 'object') { 33 | const nestedResult = this.#transform({ data: value, prefix: currentKey }) 34 | result = { ...result, ...nestedResult } 35 | } else { 36 | const envKey = currentKey.toUpperCase().replace(/[^A-Z0-9_]/g, '_') 37 | const envValue = String(value) 38 | result[envKey] = envValue 39 | } 40 | } 41 | 42 | return result 43 | } 44 | 45 | parse({ data }: { data: string }) { 46 | const { data: preparedData, format } = this.#prepare({ data }) 47 | const transformedData = this.#transform({ data: preparedData }) 48 | 49 | return { data: transformedData, format } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/validator.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | 3 | import { schema } from '@poppinss/validator-lite' 4 | 5 | import { Validator } from '../src/validator.js' 6 | import { E_CREDENTIALS_INVALID } from '../src/errors.js' 7 | 8 | test.group('Validator', () => { 9 | test('should validate and return data', async ({ assert }) => { 10 | const data = { 11 | HELLO: 'world', 12 | PORTUGAL_CAPITAL: 'Lisbon', 13 | PORTUGAL_FOOD_DESERT: 'Pastel de nata', 14 | } 15 | const validator = new Validator({ 16 | HELLO: schema.string(), 17 | PORTUGAL_CAPITAL: schema.string(), 18 | PORTUGAL_FOOD_DESERT: schema.string(), 19 | }) 20 | 21 | const validatedData = validator.validate({ 22 | data, 23 | }) 24 | 25 | assert.deepEqual(validatedData, { 26 | HELLO: 'world', 27 | PORTUGAL_CAPITAL: 'Lisbon', 28 | PORTUGAL_FOOD_DESERT: 'Pastel de nata', 29 | }) 30 | }) 31 | 32 | test('should throu an error when unable to validate', async ({ assert }) => { 33 | const data = { 34 | HELLO: 'world', 35 | PORTUGAL_CAPITAL: 'Lisbon', 36 | } 37 | const validator = new Validator({ 38 | HELLO: schema.string(), 39 | PORTUGAL_CAPITAL: schema.string(), 40 | PORTUGAL_CAPITAL_DESERT: schema.string(), 41 | }) 42 | 43 | try { 44 | validator.validate({ 45 | data, 46 | }) 47 | } catch (error) { 48 | assert.instanceOf(error, E_CREDENTIALS_INVALID) 49 | assert.deepEqual(error.help.split('\n'), [ 50 | '- Missing credentials variable "PORTUGAL_CAPITAL_DESERT"', 51 | ]) 52 | } 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /tests/filesystem.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | 3 | import { FileSystem } from '../src/filesystem.js' 4 | 5 | test.group('FileSystem', () => { 6 | const filesystem = new FileSystem() 7 | 8 | test('should return false when file does not exist', async ({ assert }) => { 9 | const exists = await filesystem.exists({ path: 'test.json' }) 10 | 11 | assert.equal(exists, false) 12 | }) 13 | 14 | test('should return true when file exists', async ({ assert, fs }) => { 15 | await fs.createJson('test.json', { 16 | foo: 'bar', 17 | }) 18 | 19 | const exists = await filesystem.exists({ path: `${fs.basePath}/test.json` }) 20 | 21 | assert.equal(exists, true) 22 | }) 23 | 24 | test('should read file content', async ({ assert, fs }) => { 25 | await fs.createJson('test.json', { foo: 'bar' }) 26 | 27 | const content = await filesystem.read({ path: `${fs.basePath}/test.json` }) 28 | 29 | assert.equal(content, JSON.stringify({ foo: 'bar' })) 30 | }) 31 | 32 | test('should write file content', async ({ assert, fs }) => { 33 | await fs.mkdir('test') 34 | await filesystem.write({ 35 | path: `${fs.basePath}/test/test.json`, 36 | data: JSON.stringify({ foo: 'bar' }), 37 | }) 38 | 39 | await assert.fileEquals('test/test.json', `${JSON.stringify({ foo: 'bar' })}`) 40 | }) 41 | 42 | test('should erase file', async ({ assert, fs }) => { 43 | await fs.mkdir('test') 44 | await filesystem.write({ 45 | path: `${fs.basePath}/test/test.json`, 46 | data: JSON.stringify({ foo: 'bar' }), 47 | }) 48 | await filesystem.erase({ path: `${fs.basePath}/test/test.json` }) 49 | 50 | await assert.fileNotExists('test/test.json') 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /commands/сreate.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @bitkidd/adonisjs-credentials 3 | * 4 | * (c) Chirill Ceban 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { flags, BaseCommand } from '@adonisjs/core/ace' 11 | 12 | import { Vault } from '../src/vault.js' 13 | import { FileSystem } from '../src/filesystem.js' 14 | 15 | export default class CredentialsCreate extends BaseCommand { 16 | static commandName = 'credentials:create' 17 | static description = 'Create a credentials file' 18 | 19 | @flags.string({ 20 | description: 'Specify an environment for credentials file (default: development)', 21 | }) 22 | declare env: string 23 | 24 | #vault = new Vault() 25 | #filesystem = new FileSystem() 26 | 27 | async run() { 28 | const env = this.env || 'development' 29 | const initialContent = `hello: world` 30 | 31 | if (!['test', 'dev', 'development'].includes(process.env.NODE_ENV || 'development')) { 32 | this.logger.error('This command can only be used in development environment') 33 | return 34 | } 35 | 36 | const key = this.#vault.generateKey({ length: 32 }) 37 | const content = this.#vault.encrypt({ data: initialContent, key }) 38 | 39 | await this.#filesystem.write({ 40 | data: key, 41 | path: this.app.makePath(`resources/credentials/${env}.key`), 42 | }) 43 | this.logger.success(`Key file for '${env}' environment successfully created`) 44 | 45 | await this.#filesystem.write({ 46 | data: content, 47 | path: this.app.makePath(`resources/credentials/${env}.credentials`), 48 | }) 49 | this.logger.success(`Credentials file for '${env}' environment successfully created`) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Configure hook 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The configure hook is called when someone runs "node ace configure " 7 | | command. You are free to perform any operations inside this function to 8 | | configure the package. 9 | | 10 | | To make things easier, you have access to the underlying "ConfigureCommand" 11 | | instance and you can use codemods to modify the source files. 12 | | 13 | */ 14 | 15 | import ConfigureCommand from '@adonisjs/core/commands/configure' 16 | import { stubsRoot } from './stubs/main.js' 17 | import { FileSystem } from './src/filesystem.js' 18 | 19 | export async function configure(command: ConfigureCommand) { 20 | const filesystem = new FileSystem() 21 | const codemods = await command.createCodemods() 22 | 23 | await codemods.updateRcFile((rcFile) => { 24 | rcFile.addCommand('@bitkidd/adonisjs-credentials/commands') 25 | }) 26 | 27 | await codemods.makeUsingStub(stubsRoot, 'start.stub', {}) 28 | 29 | try { 30 | const oldGitIgnoreFile = await filesystem.read({ path: `${command.app.makePath()}/.gitignore` }) 31 | const keyRuleExists = oldGitIgnoreFile.includes('*.key') 32 | 33 | if (keyRuleExists) { 34 | command.logger.action('update .gitignore file').skipped() 35 | } else { 36 | const newGitIgnoreFile = `${oldGitIgnoreFile}\n*.key\n` 37 | 38 | await filesystem.write({ 39 | path: `${command.app.makePath()}/.gitignore`, 40 | data: newGitIgnoreFile, 41 | }) 42 | 43 | command.logger.action('update .gitignore file').succeeded() 44 | } 45 | } catch (error) { 46 | command.logger.action('update .gitignore file').failed(error) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/credentials_create.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { AceFactory } from '@adonisjs/core/factories' 3 | 4 | import CredentialsCreate from '../commands/сreate.js' 5 | 6 | test.group('Command - Credentials Create', (group) => { 7 | group.each.teardown(async () => { 8 | delete process.env.NODE_ENV 9 | delete process.env.ADONIS_ACE_CWD 10 | }) 11 | 12 | test('should create development (default) credentials and key files', async ({ fs, assert }) => { 13 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 14 | await ace.app.init() 15 | ace.ui.switchMode('raw') 16 | 17 | const command = await ace.create(CredentialsCreate, []) 18 | await command.exec() 19 | 20 | await assert.fileExists('resources/credentials/development.key') 21 | await assert.fileExists('resources/credentials/development.credentials') 22 | }) 23 | 24 | test('should create credentials and key files for specified env', async ({ fs, assert }) => { 25 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 26 | await ace.app.init() 27 | ace.ui.switchMode('raw') 28 | 29 | const command = await ace.create(CredentialsCreate, ['--env=production']) 30 | await command.exec() 31 | 32 | await assert.fileExists('resources/credentials/production.key') 33 | await assert.fileExists('resources/credentials/production.credentials') 34 | }) 35 | 36 | test('should log an error when called in production', async ({ fs }) => { 37 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 38 | await ace.app.init() 39 | ace.ui.switchMode('raw') 40 | 41 | process.env.NODE_ENV = 'production' 42 | 43 | const command = await ace.create(CredentialsCreate, []) 44 | await command.exec() 45 | 46 | command.assertLog('[ red(error) ] This command can only be used in development environment') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /tests/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | 3 | import { Parser } from '../src/parser.js' 4 | import { E_CREDENTIALS_INVALID_FORMAT } from '../src/errors.js' 5 | 6 | test.group('Parser', () => { 7 | const parser = new Parser() 8 | 9 | test('should recogize yaml format', async ({ assert }) => { 10 | const yaml = ` 11 | hello: world 12 | portugal: 13 | capital: Lisbon 14 | ` 15 | 16 | const { format } = parser.parse({ data: yaml }) 17 | 18 | assert.equal(format, 'yaml') 19 | }) 20 | 21 | test('should recogize json format', async ({ assert }) => { 22 | const json = `{ "hello": "world", "portugal": { "capital": "Lisbon" } }` 23 | 24 | const { format } = parser.parse({ data: json }) 25 | 26 | assert.equal(format, 'json') 27 | }) 28 | 29 | test('should parse yaml format', async ({ assert }) => { 30 | const yaml = ` 31 | hello: world 32 | portugal: 33 | capital: Lisbon 34 | food: 35 | desert: Pastel de nata 36 | 37 | ` 38 | 39 | const { data } = parser.parse({ data: yaml }) 40 | 41 | assert.deepEqual(data, { 42 | HELLO: 'world', 43 | PORTUGAL_CAPITAL: 'Lisbon', 44 | PORTUGAL_FOOD_DESERT: 'Pastel de nata', 45 | }) 46 | }) 47 | 48 | test('should parse json format', async ({ assert }) => { 49 | const json = `{ "hello": "world", "portugal": { "capital": "Lisbon", "food": { "desert": "Pastel de nata" } } }` 50 | 51 | const { data } = parser.parse({ data: json }) 52 | 53 | assert.deepEqual(data, { 54 | HELLO: 'world', 55 | PORTUGAL_CAPITAL: 'Lisbon', 56 | PORTUGAL_FOOD_DESERT: 'Pastel de nata', 57 | }) 58 | }) 59 | 60 | test('should fail to parse corrupted file', async ({ assert }) => { 61 | const json = `{ "hello": "world }` 62 | 63 | try { 64 | parser.parse({ data: json }) 65 | } catch (error) { 66 | assert.instanceOf(error, E_CREDENTIALS_INVALID_FORMAT) 67 | } 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/credentials.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | 3 | import { Credentials } from '../src/credentials.js' 4 | import { Validator } from '../src/validator.js' 5 | 6 | test.group('Credentials', async () => { 7 | test('should return credentials data by key', async ({ assert, fs }) => { 8 | await fs.create('development.key', 'v2.OlNJCYbOfpYulomvTEeTun4rp5WXz3yI') 9 | await fs.create( 10 | 'development.credentials', 11 | 'v2.FFknPJtcl+V2kJj6eio3I1GYL2bRKfw9iQ8Ot4O12qeUqf6Bv0A5uHDB7UZ6KDioVMM6B0DY2HaKqpea5g8meQ==' 12 | ) 13 | 14 | const credentials = await Credentials.create( 15 | { 16 | HELLO_FROM: Credentials.schema.string(), 17 | }, 18 | { path: fs.basePath } 19 | ) 20 | const key = credentials.get('HELLO_FROM') 21 | 22 | assert.equal(key, 'adonisjs') 23 | }) 24 | 25 | test('should return default value for unknown key', async ({ assert, fs }) => { 26 | await fs.create('development.key', 'v2.OlNJCYbOfpYulomvTEeTun4rp5WXz3yI') 27 | await fs.create( 28 | 'development.credentials', 29 | 'v2.FFknPJtcl+V2kJj6eio3I1GYL2bRKfw9iQ8Ot4O12qeUqf6Bv0A5uHDB7UZ6KDioVMM6B0DY2HaKqpea5g8meQ==' 30 | ) 31 | 32 | const credentials = await Credentials.create( 33 | { 34 | HELLO_FROM: Credentials.schema.string(), 35 | }, 36 | { path: fs.basePath } 37 | ) 38 | const key = credentials.get('HELLO_UNKNOW', 'Hello Unknown') 39 | 40 | assert.equal(key, 'Hello Unknown') 41 | }) 42 | 43 | test('should return null for unknown key without default value', async ({ assert, fs }) => { 44 | await fs.create('development.key', 'v2.OlNJCYbOfpYulomvTEeTun4rp5WXz3yI') 45 | await fs.create( 46 | 'development.credentials', 47 | 'v2.FFknPJtcl+V2kJj6eio3I1GYL2bRKfw9iQ8Ot4O12qeUqf6Bv0A5uHDB7UZ6KDioVMM6B0DY2HaKqpea5g8meQ==' 48 | ) 49 | 50 | const credentials = await Credentials.create( 51 | { 52 | HELLO_FROM: Credentials.schema.string(), 53 | }, 54 | { path: fs.basePath } 55 | ) 56 | const key = credentials.get('HELLO_UNKNOW') 57 | 58 | assert.equal(key, null) 59 | }) 60 | 61 | test('should return validator rules', async ({ assert }) => { 62 | const credentials = Credentials.rules({ 63 | HELLO_FROM: Credentials.schema.string(), 64 | }) 65 | 66 | assert.instanceOf(credentials, Validator) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/credentials.ts: -------------------------------------------------------------------------------- 1 | import type { ValidateFn } from '@poppinss/validator-lite' 2 | import type { CredentialsConfig } from '../types/config.js' 3 | 4 | import { schema as credentialsSchema } from '@poppinss/validator-lite' 5 | 6 | import { Vault } from './vault.js' 7 | import { Parser } from './parser.js' 8 | import { FileSystem } from './filesystem.js' 9 | import { Validator } from './validator.js' 10 | 11 | export class Credentials> { 12 | static #config: CredentialsConfig = { 13 | path: 'resources/credentials', 14 | environment: process.env.NODE_ENV || 'development', 15 | populateEnvironment: false, 16 | } 17 | #values: CredentialValues 18 | 19 | constructor(values: CredentialValues) { 20 | this.#values = values 21 | } 22 | 23 | static schema = credentialsSchema 24 | 25 | static rules }>(schema: T): Validator { 26 | const validator = new Validator(schema) 27 | return validator 28 | } 29 | 30 | static async create }>( 31 | schema: Schema, 32 | config?: CredentialsConfig 33 | ): Promise< 34 | Credentials<{ 35 | [K in keyof Schema]: ReturnType 36 | }> 37 | > { 38 | this.#config = { ...this.#config, ...config } 39 | 40 | const vault = new Vault(this.#config) 41 | const parser = new Parser() 42 | const filesystem = new FileSystem() 43 | const validator = new Validator(schema) 44 | 45 | const key = await vault.getKey() 46 | const content = await filesystem.read({ 47 | path: `${this.#config.path}/${this.#config.environment}.credentials`, 48 | }) 49 | const decryptedContent = vault.decrypt({ data: content, key }) 50 | const { data: parsedContent } = parser.parse({ data: decryptedContent }) 51 | const validatedContent = validator.validate({ 52 | data: parsedContent, 53 | }) 54 | 55 | const credentials = new Credentials(validatedContent) 56 | 57 | return credentials 58 | } 59 | 60 | get(key: K): CredentialValues[K] 61 | get( 62 | key: K, 63 | defaultValue: Exclude 64 | ): Exclude 65 | get(key: string): string | undefined 66 | get(key: string, defaultValue: string): string 67 | get(key: string, defaultValue?: any): any { 68 | return this.#values[key] ?? defaultValue ?? null 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/COMMIT_CONVENTION.md: -------------------------------------------------------------------------------- 1 | ## Git Commit Message Convention 2 | 3 | > This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). 4 | 5 | Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. 6 | 7 | ``` js 8 | /^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ 9 | ``` 10 | 11 | ## Commit Message Format 12 | A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: 13 | 14 | > The **scope** is optional 15 | 16 | ``` 17 | feat(router): add support for prefix 18 | 19 | Prefix makes it easier to append a path to a group of routes 20 | ``` 21 | 22 | 1. `feat` is type. 23 | 2. `router` is scope and is optional 24 | 3. `add support for prefix` is the subject 25 | 4. The **body** is followed by a blank line. 26 | 5. The optional **footer** can be added after the body, followed by a blank line. 27 | 28 | ## Types 29 | Only one type can be used at a time and only following types are allowed. 30 | 31 | - feat 32 | - fix 33 | - docs 34 | - style 35 | - refactor 36 | - perf 37 | - test 38 | - workflow 39 | - ci 40 | - chore 41 | - types 42 | - build 43 | 44 | If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. 45 | 46 | ### Revert 47 | If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. 48 | 49 | ## Scope 50 | The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. 51 | 52 | ## Subject 53 | The subject contains succinct description of the change: 54 | 55 | - use the imperative, present tense: "change" not "changed" nor "changes". 56 | - don't capitalize first letter 57 | - no dot (.) at the end 58 | 59 | ## Body 60 | 61 | Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". 62 | The body should include the motivation for the change and contrast this with previous behavior. 63 | 64 | ## Footer 65 | 66 | The footer should contain any information about **Breaking Changes** and is also the place to 67 | reference GitHub issues that this commit **Closes**. 68 | 69 | **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. 70 | 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bitkidd/adonisjs-credentials", 3 | "version": "2.0.0", 4 | "description": "Credentials manager for Adonis 6.x", 5 | "type": "module", 6 | "files": [ 7 | "build/commands", 8 | "build/src", 9 | "build/stubs", 10 | "build/index.d.ts", 11 | "build/index.js", 12 | "build/configure.d.ts", 13 | "build/configure.js" 14 | ], 15 | "main": "build/index.js", 16 | "exports": { 17 | ".": "./build/index.js", 18 | "./commands": "./build/commands/main.js" 19 | }, 20 | "engines": { 21 | "node": ">=18.16.0" 22 | }, 23 | "scripts": { 24 | "clean": "del-cli build", 25 | "copy:templates": "copyfiles \"stubs/**/*.stub\" build", 26 | "typecheck": "tsc --noEmit", 27 | "lint": "eslint . --ext=.ts", 28 | "format": "prettier --write .", 29 | "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts", 30 | "pretest": "npm run lint", 31 | "test": "c8 npm run quick:test", 32 | "precompile": "npm run lint && npm run clean", 33 | "compile": "tsc", 34 | "postcompile": "npm run copy:templates && npm run index:commands", 35 | "build": "npm run compile", 36 | "index:commands": "adonis-kit index build/commands", 37 | "postbuild": "npm run copy:templates", 38 | "release": "np", 39 | "version": "npm run build", 40 | "prepublishOnly": "npm run build" 41 | }, 42 | "keywords": [ 43 | "adonis", 44 | "credentials", 45 | "secrets" 46 | ], 47 | "author": "cc@bitkidd.dev", 48 | "license": "MIT", 49 | "devDependencies": { 50 | "@adonisjs/core": "^6.2.2", 51 | "@adonisjs/eslint-config": "^1.2.1", 52 | "@adonisjs/prettier-config": "^1.2.1", 53 | "@adonisjs/tsconfig": "^1.2.1", 54 | "@japa/assert": "^2.1.0", 55 | "@japa/expect-type": "^2.0.1", 56 | "@japa/file-system": "^2.2.0", 57 | "@japa/runner": "^3.1.1", 58 | "@swc/core": "^1.3.107", 59 | "@types/node": "^20.11.16", 60 | "c8": "^9.1.0", 61 | "copyfiles": "^2.4.1", 62 | "cross-env": "^7.0.3", 63 | "del-cli": "^5.1.0", 64 | "eslint": "^8.56.0", 65 | "prettier": "^3.2.5", 66 | "ts-node": "^10.9.2", 67 | "typescript": "^5.3.3" 68 | }, 69 | "peerDependencies": { 70 | "@adonisjs/core": "^6.2.0" 71 | }, 72 | "dependencies": { 73 | "@adonisjs/assembler": "^7.1.1", 74 | "@poppinss/utils": "^6.7.1", 75 | "@poppinss/validator-lite": "^1.0.3", 76 | "dotenv": "^16.4.1", 77 | "execa": "^8.0.1", 78 | "yaml": "^2.3.4" 79 | }, 80 | "repository": { 81 | "type": "git", 82 | "url": "git+ssh://git@github.com/bitkidd/adonis-credentials.git" 83 | }, 84 | "bugs": { 85 | "url": "https://github.com/bitkidd/adonis-credentials/issues" 86 | }, 87 | "homepage": "https://github.com/bitkidd/adonis-credentials#readme", 88 | "c8": { 89 | "reporter": [ 90 | "text", 91 | "html" 92 | ], 93 | "exclude": [ 94 | "tests/**" 95 | ] 96 | }, 97 | "eslintConfig": { 98 | "extends": "@adonisjs/eslint-config/package" 99 | }, 100 | "prettier": "@adonisjs/prettier-config" 101 | } 102 | -------------------------------------------------------------------------------- /src/vault.ts: -------------------------------------------------------------------------------- 1 | import type { VaultConfig } from '../types/config.js' 2 | 3 | import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' 4 | 5 | import { 6 | E_CREDENTIALS_CANNOT_DECRYPT, 7 | E_CREDENTIALS_MISSING_KEY, 8 | E_CREDENTIALS_UNKNOWN_VERSION, 9 | } from './errors.js' 10 | import { FileSystem } from './filesystem.js' 11 | 12 | export class Vault { 13 | #version = 'v2' 14 | #ivLength = 16 15 | #algorithm = 'aes-256-cbc' 16 | #filesystem = new FileSystem() 17 | 18 | #config = { 19 | path: 'resources/credentials', 20 | environment: 'development', 21 | } 22 | 23 | constructor(config?: VaultConfig) { 24 | this.#config = { ...this.#config, ...config } 25 | } 26 | 27 | #checkVersion({ data }: { data: string }) { 28 | const [version, content] = data.replace(/\s/g, '').split('.') 29 | 30 | if (version !== this.#version) { 31 | throw new E_CREDENTIALS_UNKNOWN_VERSION() 32 | } 33 | 34 | return content 35 | } 36 | 37 | get version() { 38 | return this.#version 39 | } 40 | 41 | async getKey() { 42 | try { 43 | const key = await this.#filesystem.read({ 44 | path: `${this.#config.path}/${this.#config.environment}.key`, 45 | }) 46 | 47 | return key 48 | } catch (error) { 49 | if (process.env.APP_CREDENTIALS_KEY) { 50 | return process.env.APP_CREDENTIALS_KEY 51 | } 52 | 53 | throw new E_CREDENTIALS_MISSING_KEY() 54 | } 55 | } 56 | 57 | prepareKey({ data }: { data: string }) { 58 | return createHash('sha256').update(String(data)).digest('base64').slice(0, 32) 59 | } 60 | 61 | generateKey({ length = 32 }: { length: number }) { 62 | const keyBase = createHash('sha256') 63 | .update(randomBytes(length)) 64 | .digest('base64') 65 | .slice(0, length) 66 | 67 | return `${this.#version}.${keyBase}` 68 | } 69 | 70 | encrypt({ data, key }: { data: string; key: string }): string { 71 | if (!key.length) { 72 | throw new E_CREDENTIALS_MISSING_KEY() 73 | } 74 | 75 | const plainKey = this.#checkVersion({ data: key }) 76 | const iv = randomBytes(this.#ivLength) 77 | const buffer = Buffer.from(data) 78 | 79 | const cipher = createCipheriv(this.#algorithm, this.prepareKey({ data: plainKey }), iv) 80 | const result = Buffer.concat([iv, cipher.update(buffer), cipher.final()]) 81 | 82 | return `${this.#version}.${result.toString('base64')}` 83 | } 84 | 85 | decrypt({ data, key }: { data: string; key: string }): string { 86 | if (!key.length) { 87 | throw new E_CREDENTIALS_MISSING_KEY() 88 | } 89 | 90 | const plainKey = this.#checkVersion({ data: key }) 91 | const plainData = this.#checkVersion({ data }) 92 | 93 | const buffer = Buffer.from(plainData, 'base64') 94 | const iv = buffer.subarray(0, this.#ivLength) 95 | const content = buffer.subarray(this.#ivLength) 96 | 97 | try { 98 | const decipher = createDecipheriv(this.#algorithm, this.prepareKey({ data: plainKey }), iv) 99 | const result = Buffer.concat([decipher.update(content), decipher.final()]) 100 | 101 | return result.toString('utf-8') 102 | } catch (error) { 103 | throw new E_CREDENTIALS_CANNOT_DECRYPT() 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/credentials_edit.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { AceFactory } from '@adonisjs/core/factories' 3 | 4 | import CredentialsEdit from '../commands/edit.js' 5 | 6 | test.group('Command - Credentials Edit', () => { 7 | test('should create a tmp file and terminate command when editor is set via a flag', async ({ 8 | fs, 9 | }) => { 10 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 11 | await ace.app.init() 12 | ace.ui.switchMode('raw') 13 | 14 | await fs.create('resources/credentials/development.key', 'v2.DhVxW4Yx5gT03V5RNHecB0BtHkempOS4') 15 | await fs.create( 16 | 'resources/credentials/development.credentials', 17 | 'v2.OGs9XJwXbhPLIhud9lHrWTw2Nr9qRJ+lZXibDfOXPKE=' 18 | ) 19 | 20 | const command = await ace.create(CredentialsEdit, ['--editor=none']) 21 | await command.exec() 22 | 23 | command.assertLog(`[ green(success) ] Decrypted credentials file for 'development'`) 24 | }) 25 | 26 | test('should create a tmp file and terminate command when editor is set via process.env', async ({ 27 | fs, 28 | }) => { 29 | process.env.EDITOR = 'none' 30 | 31 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 32 | await ace.app.init() 33 | ace.ui.switchMode('raw') 34 | 35 | await fs.create('resources/credentials/development.key', 'v2.DhVxW4Yx5gT03V5RNHecB0BtHkempOS4') 36 | await fs.create( 37 | 'resources/credentials/development.credentials', 38 | 'v2.OGs9XJwXbhPLIhud9lHrWTw2Nr9qRJ+lZXibDfOXPKE=' 39 | ) 40 | 41 | const command = await ace.create(CredentialsEdit, []) 42 | await command.exec() 43 | 44 | command.assertLog(`[ green(success) ] Decrypted credentials file for 'development'`) 45 | 46 | delete process.env.EDITOR 47 | }) 48 | 49 | test('should terminate command when editor is not set', async ({ fs }) => { 50 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 51 | await ace.app.init() 52 | ace.ui.switchMode('raw') 53 | 54 | await fs.create('resources/credentials/development.key', 'v2.DhVxW4Yx5gT03V5RNHecB0BtHkempOS4') 55 | await fs.create( 56 | 'resources/credentials/development.credentials', 57 | 'v2.OGs9XJwXbhPLIhud9lHrWTw2Nr9qRJ+lZXibDfOXPKE=' 58 | ) 59 | 60 | const command = await ace.create(CredentialsEdit, []) 61 | await command.exec() 62 | 63 | command.assertLog( 64 | `[ red(error) ] Credentials editor is unset, please set it in EDITOR environment variable or pass it as a flag` 65 | ) 66 | }) 67 | 68 | test('should create a temporary file with exact content', async ({ fs, assert }) => { 69 | const ace = await new AceFactory().make(fs.baseUrl, { importer: () => {} }) 70 | await ace.app.init() 71 | ace.ui.switchMode('raw') 72 | 73 | await fs.create('resources/credentials/development.key', 'v2.DhVxW4Yx5gT03V5RNHecB0BtHkempOS4') 74 | await fs.create( 75 | 'resources/credentials/development.credentials', 76 | 'v2.OGs9XJwXbhPLIhud9lHrWTw2Nr9qRJ+lZXibDfOXPKE=' 77 | ) 78 | 79 | const command = await ace.create(CredentialsEdit, ['--editor=none']) 80 | await command.exec() 81 | 82 | const files = await fs.readDir() 83 | const yamlFile = files.find((file) => file.basename.includes('yaml')) 84 | 85 | await assert.fileContains(yamlFile!.path, `hello: world`) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /commands/edit.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @bitkidd/adonisjs-credentials 3 | * 4 | * (c) Chirill Ceban 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import 'dotenv/config' 11 | import { execa } from 'execa' 12 | import { flags, BaseCommand } from '@adonisjs/core/ace' 13 | import string from '@adonisjs/core/helpers/string' 14 | 15 | import { Vault } from '../src/vault.js' 16 | import { Parser } from '../src/parser.js' 17 | import { FileSystem } from '../src/filesystem.js' 18 | 19 | export default class CredentialsEdit extends BaseCommand { 20 | static commandName = 'credentials:edit' 21 | static description = 'Edit a credentials file' 22 | 23 | @flags.string({ 24 | description: 'Specify an environment for credentials file (default: development)', 25 | }) 26 | declare env: string 27 | 28 | @flags.string({ 29 | description: 'Specify an editor to use for edit', 30 | }) 31 | declare editor: string 32 | 33 | #vault = new Vault() 34 | #parser = new Parser() 35 | #filesystem = new FileSystem() 36 | 37 | async run() { 38 | if (!this.editor && !process.env.EDITOR) { 39 | this.logger.error( 40 | 'Credentials editor is unset, please set it in EDITOR environment variable or pass it as a flag' 41 | ) 42 | return 43 | } 44 | 45 | const env = this.env || process.env.NODE_ENV || 'development' 46 | const [editor, ...params] = this.editor?.split(' ') || process.env.EDITOR?.split(' ') 47 | const credentialsPath = this.app.makePath('resources/credentials') 48 | 49 | const keyData = await this.#filesystem.read({ path: `${credentialsPath}/${env}.key` }) 50 | const credentialsData = await this.#filesystem.read({ 51 | path: `${credentialsPath}/${env}.credentials`, 52 | }) 53 | const credentialsDataDecrypted = this.#vault.decrypt({ data: credentialsData, key: keyData }) 54 | const { format } = this.#parser.parse({ 55 | data: credentialsDataDecrypted, 56 | }) 57 | 58 | const randomUid = string.generateRandom(16) 59 | const tmpFilePath = this.app.tmpPath(`${randomUid}.${format}`) 60 | 61 | await this.#filesystem.write({ data: credentialsDataDecrypted, path: tmpFilePath }) 62 | this.logger.success(`Decrypted credentials file for '${env}'`) 63 | 64 | if (this.editor === 'none') { 65 | this.logger.info( 66 | `Command was terminated because editor is set to 'none'. Do not forget to remove the temporary file.` 67 | ) 68 | this.terminate() 69 | return 70 | } 71 | /* c8 ignore start */ 72 | try { 73 | await execa(editor, [tmpFilePath, ...params], { 74 | stdio: 'inherit', 75 | }) 76 | 77 | const updatedCredentialsData = await this.#filesystem.read({ path: tmpFilePath }) 78 | const updatedCredentialsDataEncrypted = this.#vault.encrypt({ 79 | data: updatedCredentialsData, 80 | key: keyData, 81 | }) 82 | 83 | await this.#filesystem.write({ 84 | data: updatedCredentialsDataEncrypted, 85 | path: `${credentialsPath}/${env}.credentials`, 86 | }) 87 | await this.#filesystem.erase({ path: tmpFilePath }) 88 | 89 | this.logger.success(`Credentials file for '${env}' environment was successfully updated`) 90 | 91 | await this.terminate() 92 | } catch (error) { 93 | await this.#filesystem.erase({ path: tmpFilePath }) 94 | 95 | this.logger.error(`Failed to edit credentials file for '${env}' environment.`) 96 | 97 | await this.terminate() 98 | } 99 | /* c8 ignore stop */ 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/vault.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | 3 | import { Vault } from '../src/vault.js' 4 | import { 5 | E_CREDENTIALS_CANNOT_DECRYPT, 6 | E_CREDENTIALS_MISSING_KEY, 7 | E_CREDENTIALS_UNKNOWN_VERSION, 8 | } from '../src/errors.js' 9 | 10 | test.group('Vault', () => { 11 | const vault = new Vault() 12 | let encrypted = `${vault.version}.` 13 | 14 | test('should through an error when no key defined anywhere', async ({ assert }) => { 15 | try { 16 | await vault.getKey() 17 | } catch (error) { 18 | assert.instanceOf(error, E_CREDENTIALS_MISSING_KEY) 19 | } 20 | }) 21 | 22 | test('should return a key defined in file', async ({ assert, fs }) => { 23 | delete process.env.APP_CREDENTIALS_KEY 24 | await fs.create('development.key', 'Test Key') 25 | 26 | const vaultWithPath = new Vault({ path: fs.basePath }) 27 | const key = await vaultWithPath.getKey() 28 | 29 | assert.equal(key, 'Test Key') 30 | }) 31 | 32 | test('should return a key defined in a process', async ({ assert }) => { 33 | process.env.APP_CREDENTIALS_KEY = 'Test Key' 34 | 35 | const key = await vault.getKey() 36 | 37 | assert.equal(key, 'Test Key') 38 | }) 39 | 40 | test('should generate a random key', ({ assert }) => { 41 | assert.isString(vault.generateKey({ length: 32 })) 42 | }) 43 | 44 | test('should through an error when trying to encrypt with no key set', ({ assert }) => { 45 | try { 46 | vault.encrypt({ data: 'testcontent', key: '' }) 47 | } catch (error) { 48 | assert.instanceOf(error, E_CREDENTIALS_MISSING_KEY) 49 | } 50 | }) 51 | 52 | test('should pick up key from argument', ({ assert }) => { 53 | assert.isString(vault.encrypt({ data: 'v2.testcontent', key: 'v2.testkey' })) 54 | }) 55 | 56 | test('should encrypt content', ({ assert }) => { 57 | encrypted = vault.encrypt({ data: 'v2.testcontent', key: 'v2.testkey' }) 58 | assert.isString(vault.encrypt({ data: 'v2.testcontent', key: 'v2.testkey' })) 59 | }) 60 | 61 | test('should decrypt content', ({ assert }) => { 62 | assert.equal(vault.decrypt({ data: encrypted, key: 'v2.testkey' }), 'v2.testcontent') 63 | }) 64 | 65 | test('should throw an error when trying to decrypt with no key set', ({ assert }) => { 66 | try { 67 | vault.decrypt({ data: encrypted, key: '' }) 68 | } catch (error) { 69 | assert.instanceOf(error, E_CREDENTIALS_MISSING_KEY) 70 | } 71 | }) 72 | 73 | test('should throw an error when wrong key', ({ assert }) => { 74 | try { 75 | vault.decrypt({ data: encrypted, key: 'v2.wrongkey' }) 76 | } catch (error) { 77 | assert.instanceOf(error, E_CREDENTIALS_CANNOT_DECRYPT) 78 | } 79 | }) 80 | 81 | test('should throw an error when corrupted content', ({ assert }) => { 82 | try { 83 | vault.decrypt({ data: 'v2.corruptedcontent', key: 'v2.testkey' }) 84 | } catch (error) { 85 | assert.instanceOf(error, E_CREDENTIALS_CANNOT_DECRYPT) 86 | } 87 | }) 88 | 89 | test('should throw an error when no version found with key or credentials', async ({ 90 | assert, 91 | }) => { 92 | try { 93 | vault.decrypt({ 94 | data: 'v2.FFknPJtcl+V2kJj6eio3I1GYL2bRKfw9iQ8Ot4O12qeUqf6Bv0A5uHDB7UZ6KDioVMM6B0DY2HaKqpea5g8meQ==', 95 | key: 'OlNJCYbOfpYulomvTEeTun4rp5WXz3yI', 96 | }) 97 | } catch (error) { 98 | assert.instanceOf(error, E_CREDENTIALS_UNKNOWN_VERSION) 99 | } 100 | }) 101 | 102 | test('should throw an error when versions do not correspond', async ({ assert }) => { 103 | try { 104 | vault.decrypt({ 105 | data: 'v2.FFknPJtcl+V2kJj6eio3I1GYL2bRKfw9iQ8Ot4O12qeUqf6Bv0A5uHDB7UZ6KDioVMM6B0DY2HaKqpea5g8meQ==', 106 | key: 'v3.OlNJCYbOfpYulomvTEeTun4rp5WXz3yI', 107 | }) 108 | } catch (error) { 109 | assert.instanceOf(error, E_CREDENTIALS_UNKNOWN_VERSION) 110 | } 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Table of contents 5 | 6 | - [AdonisJS Credentials](#adonisjs-credentials) 7 | - [Why it exists?](#why-it-exists) 8 | - [Installation](#installation) 9 | - [Configuration](#configuration) 10 | - [Run `ace configure`](#run-ace-configure) 11 | - [Modify `bin/server.ts` file](#modify-binserverts-file) 12 | - [Modify `bin/console.ts` file](#modify-binconsolets-file) 13 | - [Usage](#usage) 14 | - [Creating credentials](#creating-credentials) 15 | - [Editing credentials](#editing-credentials) 16 | - [Credentials schema](#credentials-schema) 17 | - [Getting credentials](#getting-credentials) 18 | - [Using in production](#using-in-production) 19 | - [How it works](#how-it-works) 20 | - [How to update from v1 to v2](#how-to-update-from-v1-to-v2) 21 | 22 | 23 | 24 | # AdonisJS Credentials 25 | 26 | > adonisjs, adonis, credentials 27 | 28 | [![workflow-image]][workflow-url] [![npm-image]][npm-url] [![license-image]][license-url] [![typescript-image]][typescript-url] 29 | 30 | AdonisJS Credentials is created to help you manage multiple environment secrets, share them securely and even keep them inside your repo. 31 | 32 | Inspired by Rails Credentials. 33 | 34 | ## Why it exists? 35 | 36 | Let's say you decided to build a real world app and your app will connect to a database, a mail provider, it will have some social authentication, you may upload files to a cloud storage and here you are, with +/- 15-20 secret keys that you'll have to manage in your `.env` file, share with your team and somehow sync with he server. 37 | 38 | That's a tedious task, trust me. One of my apps has 100+ secrets and all of them had to be set up by hand. 39 | 40 | Here comes this package, it allows you to take all of these secrets and keys, pack them into an easily readable `.yaml` file, encrypt its content into a very long secure string and keep it inside you git repo. 41 | 42 | This way you, your team and your server can get access to all of these secrets just by reading this string kept in a `.credentials` file and all what they need is a `.key` file or its content set in a single `.env` variable `APP_CREDENTIALS_KEY`. 43 | 44 | ## Installation 45 | 46 | To install the provider run: 47 | 48 | ```bash 49 | npm install @bitkidd/adonisjs-credentials 50 | # or 51 | yarn add @bitkidd/adonisjs-credentials 52 | ``` 53 | 54 | ## Configuration 55 | 56 | To configure credentials provider, we should proceed with 4 steps: 57 | 58 | #### Run `ace configure` 59 | 60 | ``` 61 | node ace configure @bitkidd/adonisjs-credentials 62 | ``` 63 | 64 | This will: 65 | 66 | - Add two new commands to your app and will allow to create and edit credentials 67 | - Add a new rule to your `.gitignore` file that will exclude all `*.key` files from repository and will not allow to commit them 68 | - Add a new `credentials.ts` file inside `/start` folder 69 | 70 | #### Modify `bin/server.ts` file 71 | 72 | As a next step you need to modify the `bin/server.ts` file and add a new line inside it: 73 | 74 | ```ts 75 | ... 76 | app.booting(async () => { 77 | await import('#start/env') 78 | await import('#start/credentials') // <--- Import credentials here 79 | }) 80 | ... 81 | ``` 82 | 83 | #### Modify `bin/console.ts` file 84 | 85 | Next you need to modify the `bin/console.ts` file and add a new line inside it: 86 | 87 | ```ts 88 | ... 89 | app.booting(async () => { 90 | await import('#start/env') 91 | await import('#start/credentials') // <--- Import credentials here 92 | }) 93 | ... 94 | ``` 95 | 96 | This will allow commands and app that they will start get access to credentials. 97 | 98 | ## Usage 99 | 100 | #### Creating credentials 101 | 102 | As you configured the provider, you may now create your first credentials by running the command: 103 | 104 | ```bash 105 | # node ace credentials:create 106 | # --- 107 | # Flags 108 | # --env string Specify an environment for credentials file (default: development) 109 | 110 | node ace credentials:create 111 | ``` 112 | 113 | This will create a new directory in your `resources` folder, called `credentials` and will add there two new files, `development.key` and `development.credentials`. 114 | 115 | Obviously, the `.key` file keeps your password to the credentials file, **do not commit any .key files to your git repo**, please check your `.gitignore` for `*.key` exclusion rule. 116 | 117 | `.key` should be kept somewhere in a secret place, the best spot I know is a sticky note on your laptop. Just NO, don't do this :see_no_evil: 118 | Keep your secrets in a secure place and use password managers! 119 | 120 | `.credentials` file can be committed and shared as it is impossible to decrypt without the key. 121 | 122 | These two files should always be kept in one folder while in development. 123 | 124 | #### Editing credentials 125 | 126 | To edit a newly created file, you should run a command: 127 | 128 | ```bash 129 | # node ace credentials:edit 130 | # --- 131 | # Flags 132 | # --env string Specify an environment for credentials file (default: development) 133 | # --editor string Specify an editor to use for edit 134 | 135 | node ace credentials:edit --editor="code ---wait" --env=development 136 | # or 137 | node ace credentials:edit --editor=nano --env=development 138 | ``` 139 | 140 | You can also add `EDITOR='code --wait'` to your `.env` file to omit `--editor` flag. 141 | 142 | This will decrypt the credentials file, create a temporary one and open it in the editor you specified. As you finish editing, close the file (or tab inside your editor), this will encrypt it back again and remove the temporary file, to keep you safe and sound. 143 | 144 | #### Credentials schema 145 | 146 | Credentials storage mimics core AdonisJS `env` package and provides the same schema validation. 147 | 148 | Your newly created `#start/credentials` file looks like this: 149 | 150 | ```ts 151 | import { Credentials } from '@bitkidd/adonisjs-credentials' 152 | 153 | export default await Credentials.create({ 154 | HELLO: Credentials.schema.string(), 155 | }) 156 | ``` 157 | 158 | And you can easily include your database credentials inside it likewise: 159 | 160 | ```ts 161 | import { Credentials } from '@bitkidd/adonisjs-credentials' 162 | 163 | export default await Credentials.create({ 164 | HELLO: Credentials.schema.string(), 165 | 166 | DB_HOST: Credentials.schema.string({ format: 'host' }), 167 | DB_PORT: Credentials.schema.number(), 168 | DB_USER: Credentials.schema.string(), 169 | DB_PASSWORD: Credentials.schema.string.optional(), 170 | DB_DATABASE: Credentials.schema.string(), 171 | }) 172 | ``` 173 | 174 | #### Getting credentials 175 | 176 | To get your secret from credentials storage you can import directly `#starts/credentials`. Foe example: 177 | 178 | ```ts 179 | import credentials from '#start/credentials' 180 | import { defineConfig } from '@adonisjs/lucid' 181 | 182 | const dbConfig = defineConfig({ 183 | connection: 'postgres', 184 | connections: { 185 | postgres: { 186 | client: 'pg', 187 | connection: { 188 | host: credentials.get('DB_HOST'), 189 | port: credentials.get('DB_PORT'), 190 | user: credentials.get('DB_USER'), 191 | password: credentials.get('DB_PASSWORD'), 192 | database: credentials.get('DB_DATABASE'), 193 | }, 194 | migrations: { 195 | naturalSort: true, 196 | paths: ['database/migrations'], 197 | }, 198 | debug: true, 199 | }, 200 | }, 201 | }) 202 | 203 | export default dbConfig 204 | ``` 205 | 206 | #### Using in production 207 | 208 | You can have multiple credential files, the best way to work is to create one for each environment, for example: development, production, staging, test and etc. 209 | 210 | As for development you can keep `.key` files inside `/credentials` folder, in a production environment this is not a great option. 211 | 212 | For production you should set additional environment variable `APP_CREDENTIALS_KEY`, that will be used to decrypt data and populate it to your app. 213 | 214 | ## How it works 215 | 216 | The provider uses node.js' native crypto library and encrypts everything using _AES_ cipher with a random vector, which makes your secrets very secure, with a single key that can decrypt data. 217 | 218 | Credentials while decrypted present themselves as simple files in YAML format, this allows you to keep variables in a very predictable and simple form: 219 | 220 | ```yaml 221 | google: 222 | key: 'your_google_key' 223 | secret: 'your_google_secret' 224 | ``` 225 | 226 | Which then is being transformed to something like this: 227 | 228 | ``` 229 | GOOGLE_KEY=your_google_key 230 | GOOGLE_SECRET=your_google_secret 231 | ``` 232 | 233 | ## How to update from v1 to v2 234 | 235 | The new version introduced one main change you'll have to apply manually. You have to add `v2.` to both of your `.key` and `.credentials` files. This will allow in the future change encryption algo without breaking things. 236 | 237 | Another thing that has changed is the default file format for `.credentials`, it will always be YAML from now. JSON files will still work, but YAML is just way easier to format and work with. 238 | 239 | [workflow-image]: https://img.shields.io/github/actions/workflow/status/bitkidd/adonisjs-credentials/test.yml?style=for-the-badge&logo=github 240 | [workflow-url]: https://github.com/bitkidd/adonisjs-credentials/actions/workflows/test.yml 241 | [npm-image]: https://img.shields.io/npm/v/@bitkidd/adonisjs-credentials.svg?style=for-the-badge&logo=npm 242 | [npm-url]: https://npmjs.org/package/@bitkidd/adonisjs-credentials 'npm' 243 | [license-image]: https://img.shields.io/npm/l/@bitkidd/adonisjs-credentials?color=blueviolet&style=for-the-badge 244 | [license-url]: LICENSE.md 'license' 245 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 246 | [typescript-url]: "typescript" 247 | --------------------------------------------------------------------------------