feat(api-key): Add CRUD functionalities to the api key module

This commit is contained in:
Stevche Radevski
2024-02-21 11:19:22 +01:00
parent e0750bae40
commit c99ca5cc22
14 changed files with 812 additions and 42 deletions

View File

@@ -1 +1,14 @@
// noop
import { CreateApiKeyDTO } from "@types"
import { ApiKeyType } from "@medusajs/utils"
export const createSecretKeyFixture: CreateApiKeyDTO = {
title: "Secret key",
type: ApiKeyType.SECRET,
created_by: "test",
}
export const createPublishableKeyFixture: CreateApiKeyDTO = {
title: "Test API Key",
type: ApiKeyType.PUBLISHABLE,
created_by: "test",
}

View File

@@ -1,19 +1,266 @@
import crypto from "crypto"
import { Modules } from "@medusajs/modules-sdk"
import { IApiKeyModuleService } from "@medusajs/types"
import { ApiKeyType } from "@medusajs/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import {
createSecretKeyFixture,
createPublishableKeyFixture,
} from "../__fixtures__"
jest.setTimeout(100000)
const mockPublishableKeyBytes = () => {
jest.spyOn(crypto, "randomBytes").mockImplementationOnce(() => {
return Buffer.from(
"44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
"hex"
)
})
}
const mockSecretKeyBytes = () => {
jest
.spyOn(crypto, "randomBytes")
.mockImplementationOnce(() => {
return Buffer.from(
"44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
"hex"
)
})
.mockImplementationOnce(() => {
return Buffer.from("44de31ebcf085fa423fc584aa8540670", "hex")
})
}
moduleIntegrationTestRunner({
moduleName: Modules.API_KEY,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IApiKeyModuleService>) => {
afterEach(() => {
jest.restoreAllMocks()
})
describe("API Key Module Service", () => {
describe("noop", () => {
it("should run", function () {
expect(true).toBe(true)
describe("creating a publishable API key", () => {
it("should create it successfully", async function () {
mockPublishableKeyBytes()
const apiKey = await service.create(createPublishableKeyFixture)
expect(apiKey).toEqual(
expect.objectContaining({
title: "Test API Key",
type: ApiKeyType.PUBLISHABLE,
salt: "",
created_by: "test",
last_used_at: null,
revoked_by: null,
revoked_at: null,
redacted: "pk_44d***3be",
token:
"pk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
})
)
})
})
describe("creating a secret API key", () => {
it("should get created successfully", async function () {
mockSecretKeyBytes()
const apiKey = await service.create(createSecretKeyFixture)
expect(apiKey).toEqual(
expect.objectContaining({
title: "Secret key",
type: ApiKeyType.SECRET,
salt: "44de31ebcf085fa423fc584aa8540670",
created_by: "test",
last_used_at: null,
revoked_by: null,
revoked_at: null,
redacted: "sk_44d***3be",
token:
"sk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
})
)
})
it("should only allow creating one active token", async function () {
expect(
service.create([createSecretKeyFixture, createSecretKeyFixture])
).rejects.toThrow(
"You can only create one secret key at a time. You tried to create 2 secret keys."
)
await service.create(createSecretKeyFixture)
const err = await service
.create(createSecretKeyFixture)
.catch((e) => e)
expect(err.message).toEqual(
"You can only have one active secret key a time. Revoke or delete your existing key before creating a new one."
)
})
it("should allow for at most two tokens, where one is revoked", async function () {
const firstApiKey = await service.create(createSecretKeyFixture)
await service.revoke({
id: firstApiKey.id,
revoked_by: "test",
})
await service.create(createSecretKeyFixture)
const err = await service
.create(createSecretKeyFixture)
.catch((e) => e)
expect(err.message).toEqual(
"You can only have one active secret key a time. Revoke or delete your existing key before creating a new one."
)
})
})
describe("revoking API keys", () => {
it("should have the revoked at and revoked by set when a key is revoked", async function () {
const firstApiKey = await service.create(createSecretKeyFixture)
const revokedKey = await service.revoke({
id: firstApiKey.id,
revoked_by: "test",
})
expect(revokedKey).toEqual(
expect.objectContaining({
revoked_by: "test",
revoked_at: expect.any(Date),
})
)
})
it("should not allow revoking an already revoked API key", async function () {
const firstApiKey = await service.create(createSecretKeyFixture)
await service.revoke({
id: firstApiKey.id,
revoked_by: "test",
})
const err = await service
.revoke({
id: firstApiKey.id,
revoked_by: "test2",
})
.catch((e) => e)
expect(err.message).toEqual(
`There are 1 secret keys that are already revoked.`
)
})
})
describe("updating an API key", () => {
it("should update the name successfully", async function () {
const createdApiKey = await service.create(createSecretKeyFixture)
const updatedApiKey = await service.update({
id: createdApiKey.id,
title: "New Name",
})
expect(updatedApiKey.title).toEqual("New Name")
})
it("should not reflect any updates on other fields", async function () {
const createdApiKey = await service.create(createSecretKeyFixture)
const updatedApiKey = await service.update({
id: createdApiKey.id,
title: createdApiKey.title,
revoked_by: "test",
revoked_at: new Date(),
last_used_at: new Date(),
})
// These should not be returned on an update
createdApiKey.token = ""
createdApiKey.salt = ""
expect(createdApiKey).toEqual(updatedApiKey)
})
})
describe("deleting API keys", () => {
it("should successfully delete existing api keys", async function () {
const createdApiKeys = await service.create([
createPublishableKeyFixture,
createSecretKeyFixture,
])
await service.delete([createdApiKeys[0].id, createdApiKeys[1].id])
const apiKeysInDatabase = await service.list()
expect(apiKeysInDatabase).toHaveLength(0)
})
})
describe("retrieving API keys", () => {
it("should successfully return all existing api keys", async function () {
await service.create([
createPublishableKeyFixture,
createSecretKeyFixture,
])
const apiKeysInDatabase = await service.list()
expect(apiKeysInDatabase).toHaveLength(2)
})
it("should not return the token and salt for secret keys when listing", async function () {
await service.create([createSecretKeyFixture])
const apiKeysInDatabase = await service.list()
expect(apiKeysInDatabase).toHaveLength(1)
expect(apiKeysInDatabase[0].token).toBeFalsy()
expect(apiKeysInDatabase[0].salt).toBeFalsy()
})
it("should return the token for publishable keys when listing", async function () {
await service.create([createPublishableKeyFixture])
const apiKeysInDatabase = await service.list()
expect(apiKeysInDatabase).toHaveLength(1)
expect(apiKeysInDatabase[0].token).toBeTruthy()
expect(apiKeysInDatabase[0].salt).toBeFalsy()
})
it("should not return the token and salt for secret keys when listing and counting", async function () {
await service.create([createSecretKeyFixture])
const [apiKeysInDatabase] = await service.listAndCount()
expect(apiKeysInDatabase).toHaveLength(1)
expect(apiKeysInDatabase[0].token).toBeFalsy()
expect(apiKeysInDatabase[0].salt).toBeFalsy()
})
it("should return the token for publishable keys when listing and counting", async function () {
await service.create([createPublishableKeyFixture])
const [apiKeysInDatabase] = await service.listAndCount()
expect(apiKeysInDatabase).toHaveLength(1)
expect(apiKeysInDatabase[0].token).toBeTruthy()
expect(apiKeysInDatabase[0].salt).toBeFalsy()
})
it("should not return the token and salt for secret keys when retrieving", async function () {
const [createdApiKey] = await service.create([createSecretKeyFixture])
const apiKeyInDatabase = await service.retrieve(createdApiKey.id)
expect(apiKeyInDatabase.token).toBeFalsy()
expect(apiKeyInDatabase.salt).toBeFalsy()
})
it("should return the token for publishable keys when retrieving", async function () {
const [createdApiKey] = await service.create([
createPublishableKeyFixture,
])
const apiKeyInDatabase = await service.retrieve(createdApiKey.id)
expect(apiKeyInDatabase.token).toBeTruthy()
expect(apiKeyInDatabase.salt).toBeFalsy()
})
})
})

View File

@@ -0,0 +1,140 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"token": {
"name": "token",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"salt": {
"name": "salt",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"redacted": {
"name": "redacted",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"title": {
"name": "title",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
},
"created_by": {
"name": "created_by",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"revoked_by": {
"name": "revoked_by",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "api_key",
"schema": "public",
"indexes": [
{
"keyName": "IDX_api_key_type",
"columnNames": [
"type"
],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_type\" ON \"api_key\" (type)"
},
{
"keyName": "api_key_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
}
]
}

View File

@@ -0,0 +1,12 @@
import { Migration } from "@mikro-orm/migrations"
export class InitialSetup20240220155605 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "api_key" ("id" text not null, "token" text not null, "salt" text not null, "redacted" text not null, "title" text not null, "type" text not null, "last_used_at" timestamptz null, "created_by" text not null, "created_at" timestamptz not null default now(), "revoked_by" text null, "revoked_at" timestamptz null, constraint "api_key_pkey" primary key ("id"));'
)
this.addSql(
'CREATE INDEX IF NOT EXISTS "IDX_api_key_type" ON "api_key" (type);'
)
}
}

View File

@@ -1,4 +1,7 @@
import { generateEntityId } from "@medusajs/utils"
import {
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
@@ -6,14 +9,45 @@ import {
OnInit,
PrimaryKey,
Property,
Enum,
} from "@mikro-orm/core"
// TODO:
const TypeIndex = createPsqlIndexStatementHelper({
tableName: "api_key",
columns: "type",
})
@Entity()
export default class ApiKey {
@PrimaryKey({ columnType: "text" })
id: string
@Property({ columnType: "text" })
token: string
@Property({ columnType: "text" })
salt: string
@Property({ columnType: "text" })
redacted: string
@Property({ columnType: "text" })
title: string
@Property({ columnType: "text" })
@Enum({ items: ["publishable", "secret"] })
@TypeIndex.MikroORMIndex()
type: "publishable" | "secret"
@Property({
columnType: "timestamptz",
nullable: true,
})
last_used_at: Date | null = null
@Property({ columnType: "text" })
created_by: string
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
@@ -21,13 +55,14 @@ export default class ApiKey {
})
created_at: Date
@Property({ columnType: "text", nullable: true })
revoked_by: string | null = null
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
nullable: true,
})
updated_at: Date
revoked_at: Date | null = null
@BeforeCreate()
onCreate() {

View File

@@ -1,3 +1,5 @@
import crypto from "crypto"
import util from "util"
import {
Context,
DAL,
@@ -6,16 +8,21 @@ import {
ModulesSdkTypes,
InternalModuleDeclaration,
ModuleJoinerConfig,
FindConfig,
} from "@medusajs/types"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { ApiKey } from "@models"
import { CreateApiKeyDTO, TokenDTO } from "@types"
import {
ApiKeyType,
InjectManager,
InjectTransactionManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { ApiKey } from "@models"
const scrypt = util.promisify(crypto.scrypt)
const generateMethodForModels = []
@@ -65,28 +72,60 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]> {
const createdApiKeys = await this.create_(data, sharedContext)
const [createdApiKeys, generatedTokens] = await this.create_(
Array.isArray(data) ? data : [data],
sharedContext
)
return await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(createdApiKeys, {
populate: true,
})
// When creating we want to return the raw token, as this will be the only time the user will be able to take note of it for future use.
const responseWithRawToken = serializedResponse.map((key) => ({
...key,
token:
generatedTokens.find((t) => t.hashedToken === key.token)?.rawToken ??
key.token,
}))
return Array.isArray(data) ? responseWithRawToken : responseWithRawToken[0]
}
@InjectTransactionManager("baseRepository_")
protected async create_(
data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[],
data: ApiKeyTypes.CreateApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity | TEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
): Promise<[TEntity[], TokenDTO[]]> {
await this.validateCreateApiKeys(data, sharedContext)
const normalizedInput: CreateApiKeyDTO[] = []
const generatedTokens: TokenDTO[] = []
for (const key of data) {
let tokenData: TokenDTO
if (key.type === ApiKeyType.PUBLISHABLE) {
tokenData = ApiKeyModuleService.generatePublishableKey()
} else {
tokenData = await ApiKeyModuleService.generateSecretKey()
}
generatedTokens.push(tokenData)
normalizedInput.push({
...key,
token: tokenData.hashedToken,
salt: tokenData.salt,
redacted: tokenData.redacted,
})
}
const createdApiKeys = await this.apiKeyService_.create(
data_,
normalizedInput,
sharedContext
)
return Array.isArray(data) ? createdApiKeys : createdApiKeys[0]
return [createdApiKeys, generatedTokens]
}
update(
@@ -103,31 +142,148 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
const updatedApiKeys = await this.update_(data, sharedContext)
const updatedApiKeys = await this.update_(
Array.isArray(data) ? data : [data],
sharedContext
)
return await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]
>(updatedApiKeys, {
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(updatedApiKeys.map(omitToken), {
populate: true,
})
return Array.isArray(data) ? serializedResponse : serializedResponse[0]
}
@InjectTransactionManager("baseRepository_")
protected async update_(
data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO,
data: ApiKeyTypes.UpdateApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[] | TEntity> {
return []
): Promise<TEntity[]> {
const updateRequest = data.map((k) => ({
id: k.id,
title: k.title,
}))
const updatedApiKeys = await this.apiKeyService_.update(
updateRequest,
sharedContext
)
return updatedApiKeys
}
@InjectManager("baseRepository_")
async retrieve(
id: string,
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO> {
const apiKey = await this.apiKeyService_.retrieve(id, config, sharedContext)
return await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO>(
omitToken(apiKey),
{
populate: true,
}
)
}
@InjectManager("baseRepository_")
async list(
filters?: ApiKeyTypes.FilterableApiKeyProps,
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]> {
const apiKeys = await this.apiKeyService_.list(
filters,
config,
sharedContext
)
return this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
apiKeys.map(omitToken),
{
populate: true,
}
)
}
@InjectManager("baseRepository_")
async listAndCount(
filters?: ApiKeyTypes.FilterableApiKeyProps,
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
sharedContext?: Context
): Promise<[ApiKeyTypes.ApiKeyDTO[], number]> {
const result = await this.apiKeyService_.listAndCount(
filters,
config,
sharedContext
)
const withoutToken = result[0].map(omitToken)
const count = result[1]
return [
await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
withoutToken,
{
populate: true,
}
),
count,
]
}
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO[],
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
@InjectManager("baseRepository_")
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO[] | ApiKeyTypes.RevokeApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
const revokedApiKeys = await this.revoke_(
Array.isArray(data) ? data : [data],
sharedContext
)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(revokedApiKeys.map(omitToken), {
populate: true,
})
return Array.isArray(data) ? serializedResponse : serializedResponse[0]
}
@InjectTransactionManager("baseRepository_")
async revoke(
id: string,
async revoke_(
data: ApiKeyTypes.RevokeApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
return
): Promise<TEntity[]> {
await this.validateRevokeApiKeys(data)
const updateRequest = data.map((k) => ({
id: k.id,
revoked_at: new Date(),
revoked_by: k.revoked_by,
}))
const revokedApiKeys = await this.apiKeyService_.update(
updateRequest,
sharedContext
)
return revokedApiKeys
}
// TODO: Implement
@InjectTransactionManager("baseRepository_")
authenticate(
id: string,
@@ -135,4 +291,108 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
): Promise<boolean> {
return Promise.resolve(false)
}
protected async validateCreateApiKeys(
data: ApiKeyTypes.CreateApiKeyDTO[],
sharedContext: Context = {}
): Promise<void> {
if (!data.length) {
return
}
// There can only be 2 secret keys at most, and one has to be with a revoked_at date set, so only 1 can be newly created.
const secretKeysToCreate = data.filter((k) => k.type === ApiKeyType.SECRET)
if (secretKeysToCreate.length > 1) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`You can only create one secret key at a time. You tried to create ${secretKeysToCreate.length} secret keys.`
)
}
// There already is a key that is not set to expire/or it hasn't expired
const dbSecretKeys = await this.apiKeyService_.list(
{
type: ApiKeyType.SECRET,
revoked_at: null,
},
{},
sharedContext
)
if (dbSecretKeys.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`You can only have one active secret key a time. Revoke or delete your existing key before creating a new one.`
)
}
}
protected async validateRevokeApiKeys(
data: ApiKeyTypes.RevokeApiKeyDTO[],
sharedContext: Context = {}
): Promise<void> {
if (!data.length) {
return
}
if (data.some((k) => !k.revoked_by)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`You must provide a revoked_by field when revoking a key.`
)
}
const revokedApiKeys = await this.apiKeyService_.list(
{
id: data.map((k) => k.id),
type: ApiKeyType.SECRET,
revoked_at: { $ne: null },
},
{},
sharedContext
)
if (revokedApiKeys.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`There are ${revokedApiKeys.length} secret keys that are already revoked.`
)
}
}
// These are public keys, so there is no point hashing them.
protected static generatePublishableKey(): TokenDTO {
const token = "pk_" + crypto.randomBytes(32).toString("hex")
return {
rawToken: token,
hashedToken: token,
salt: "",
redacted: redactKey(token),
}
}
protected static async generateSecretKey(): Promise<TokenDTO> {
const token = "sk_" + crypto.randomBytes(32).toString("hex")
const salt = crypto.randomBytes(16).toString("hex")
const hashed = ((await scrypt(token, salt, 64)) as Buffer).toString("hex")
return {
rawToken: token,
hashedToken: hashed,
salt,
redacted: redactKey(token),
}
}
}
// We are mutating the object here as what microORM relies on non-enumerable fields for serialization, among other things.
const omitToken = (key: ApiKey): ApiKey => {
key.token = key.type === ApiKeyType.SECRET ? "" : key.token
key.salt = ""
return key
}
const redactKey = (key: string): string => {
return [key.slice(0, 6), key.slice(-3)].join("***")
}

View File

@@ -1,6 +1,23 @@
import { ApiKeyType } from "@medusajs/types"
import { IEventBusModuleService, Logger } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
eventBusService?: IEventBusModuleService
}
export type CreateApiKeyDTO = {
token: string
salt: string
redacted: string
title: string
type: ApiKeyType
created_by: string
}
export type TokenDTO = {
rawToken: string
hashedToken: string
salt: string
redacted: string
}