Adonis 6 use Redis as storage for Auth Tokens #4387
-
In V5 the instructions included steps to leverage Redis as storage for auth tokens which was nice as when setting expiry they would get deleted automatically, in V6 I am not finding anything to make this happen and it seems the core code doesn't account for any config capabilities to handle this either. Is this in the plan or exist already and it's just not in the docs? |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 3 replies
-
Yes, it is planned to have a Redis provider for the same |
Beta Was this translation helpful? Give feedback.
-
I believe this is not the best way, but I adapted the database provider for Redis. To use it this way, you need to have the @adonisjs/redis package installed. Create the class below: /*
* @adonisjs/auth
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { AccessToken } from '@adonisjs/auth/access_tokens'
import { AccessTokensProviderContract } from '@adonisjs/auth/types/access_tokens'
import { RuntimeException } from '@adonisjs/core/exceptions'
import { cuid, type Secret } from '@adonisjs/core/helpers'
import type { LucidModel } from '@adonisjs/lucid/types/model'
import redis from '@adonisjs/redis/services/main'
import { RedisConnections } from '@adonisjs/redis/types'
/**
* Options accepted by the tokens provider that uses lucid
* database service to fetch and persist tokens.
*/
type RedisAccessTokensProviderOptions<TokenableModel extends LucidModel> = {
/**
* The user model for which to generate tokens. Note, the model
* is not used for tokens, but is used to associate a user
* with the token
*/
tokenableModel: TokenableModel
/**
* Redis connection to use for querying tokens.
*
* Defaults to "main"
*/
redisConnection?: keyof RedisConnections
/**
* The default expiry for all the tokens. You can also customize
* expiry at the time of creating a token as well.
*
* By default tokens do not expire
*/
expiresIn?: string | number
/**
* The length for the token secret. A secret is a cryptographically
* secure random string.
*
* Defaults to 40
*/
tokenSecretLength?: number
/**
* A unique type for the value. The type is used to identify a
* bucket of tokens within the storage layer.
*
* Defaults to auth_token
*/
type?: string
/**
* A unique prefix to append to the publicly shared token value.
*
* Defaults to oat_
*/
prefix?: string
}
/**
* The redis object properties expected at the database level
*/
type AccessTokenRedisPersisted = {
/**
* Token primary key. It can be an integer, bigInteger or
* even a UUID or any other string based value.
*
* The id should not have ". (dots)" inside it.
*/
id: number | string | BigInt
/**
* The user or entity for whom the token is
* generated
*/
tokenable_id: string | number | BigInt
/**
* A unique type for the token. It is used to
* unique identify tokens within the storage
* layer.
*/
type: string
/**
* Optional name for the token
*/
name: string | null
/**
* Token hash is used to verify the token shared
* with the user
*/
hash: string
/**
* Timestamps
*/
created_at: number
updated_at: number
/**
* An array of abilities stored as JSON.
*/
abilities: string
/**
* The date after which the token will be considered
* expired.
*
* A null value means the token is long-lived
*/
expires_at: null | number
/**
* Last time the token was used for authentication
*/
last_used_at: null | number
}
/**
* RedisAccessTokensProvider uses redis service to fetch and
* persist tokens for a given user.
*
* The user must be an instance of the associated user model.
*/
export class RedisAccessTokensProvider<TokenableModel extends LucidModel>
implements AccessTokensProviderContract<TokenableModel>
{
/**
* Create tokens provider instance for a given Lucid model
*/
static forModel<TokenableModel extends LucidModel>(
model: RedisAccessTokensProviderOptions<TokenableModel>['tokenableModel'],
options?: Omit<RedisAccessTokensProviderOptions<TokenableModel>, 'tokenableModel'>
) {
return new RedisAccessTokensProvider<TokenableModel>({
tokenableModel: model,
...(options || {}),
})
}
/**
* A unique type for the value. The type is used to identify a
* bucket of tokens within the storage layer.
*
* Defaults to auth_token
*/
protected type: string
/**
* A unique prefix to append to the publicly shared token value.
*
* Defaults to oat
*/
protected prefix: string
/**
* Redis connection to use for querying access tokens
*/
protected redisConnection: keyof RedisConnections
/**
* The length for the token secret. A secret is a cryptographically
* secure random string.
*/
protected tokenSecretLength: number
constructor(protected options: RedisAccessTokensProviderOptions<TokenableModel>) {
this.redisConnection = options.redisConnection || 'main'
this.tokenSecretLength = options.tokenSecretLength || 40
this.type = options.type || 'auth_token'
this.prefix = options.prefix || 'oat_'
}
/**
* Ensure the provided user is an instance of the user model and
* has a primary key
*/
#ensureIsPersisted(user: InstanceType<TokenableModel>) {
const model = this.options.tokenableModel
if (user instanceof model === false) {
throw new RuntimeException(
`Invalid user object. It must be an instance of the "${model.name}" model`
)
}
if (!user.$primaryKeyValue) {
throw new RuntimeException(
`Cannot use "${model.name}" model for managing access tokens. The value of column "${model.primaryKey}" is undefined or null`
)
}
}
#getRedisConnection() {
const model = this.options.tokenableModel
if (!this.redisConnection) {
throw new RuntimeException(
`Missing "redisConnection" property for auth redis provider inside "${model.name}" file.`
)
}
return redis.connection(this.redisConnection)
}
/**
* Parse the stringified redis token value to an object
*/
#parseToken(token: string | null): null | AccessTokenRedisPersisted {
if (!token) {
return null
}
try {
const tokenRow: any = JSON.parse(token)
if (!tokenRow.hash || !tokenRow.name || !tokenRow.tokenable_id) {
return null
}
return tokenRow
} catch {
return null
}
}
/**
* Maps a redis row to an instance token instance
*/
protected redisRowToAccessToken(redisRow: AccessTokenRedisPersisted): AccessToken {
return new AccessToken({
identifier: redisRow.id,
tokenableId: redisRow.tokenable_id,
type: redisRow.type,
name: redisRow.name,
hash: redisRow.hash,
abilities: JSON.parse(redisRow.abilities),
createdAt:
typeof redisRow.created_at === 'number'
? new Date(redisRow.created_at)
: redisRow.created_at,
updatedAt:
typeof redisRow.updated_at === 'number'
? new Date(redisRow.updated_at)
: redisRow.updated_at,
lastUsedAt:
typeof redisRow.last_used_at === 'number'
? new Date(redisRow.last_used_at)
: redisRow.last_used_at,
expiresAt:
typeof redisRow.expires_at === 'number'
? new Date(redisRow.expires_at)
: redisRow.expires_at,
})
}
/**
* Create a token for a user
*/
async create(
user: InstanceType<TokenableModel>,
abilities: string[] = ['*'],
options?: {
name?: string
expiresIn?: string | number
}
) {
this.#ensureIsPersisted(user)
/**
* Creating a transient token. Transient token abstracts
* the logic of creating a random secure secret and its
* hash
*/
const transientToken = AccessToken.createTransientToken(
user.$primaryKeyValue!,
this.tokenSecretLength,
options?.expiresIn || this.options.expiresIn
)
/**
* Object to persist in redis.
*/
const redisRow: Omit<AccessTokenRedisPersisted, 'id'> = {
tokenable_id: transientToken.userId,
type: this.type,
name: options?.name || null,
hash: transientToken.hash,
abilities: JSON.stringify(abilities),
created_at: new Date().valueOf(),
updated_at: new Date().valueOf(),
last_used_at: null,
expires_at: transientToken.expiresAt?.valueOf() || null,
}
/**
* Token ttl
*/
let ttl = 0
if (transientToken.expiresAt) {
ttl = Math.ceil(
transientToken.expiresAt.getTime() / 1000 - Math.floor(new Date().getTime() / 1000)
)
}
const id = cuid()
if (transientToken.expiresAt && ttl <= 0) {
throw new RuntimeException('The expiry date/time should be in the future')
}
/**
* Insert data to redis.
*/
if (transientToken.expiresAt) {
await this.#getRedisConnection().setex(`${this.type}:${id}`, ttl, JSON.stringify(redisRow))
} else {
await this.#getRedisConnection().set(`${this.type}:${id}`, JSON.stringify(redisRow))
}
/**
* Convert db row to an access token
*/
return new AccessToken({
identifier: id,
tokenableId: redisRow.tokenable_id,
type: redisRow.type,
prefix: this.prefix,
secret: transientToken.secret,
name: redisRow.name,
hash: redisRow.hash,
abilities: JSON.parse(redisRow.abilities),
createdAt: new Date(redisRow.created_at),
updatedAt: new Date(redisRow.updated_at),
lastUsedAt: redisRow.last_used_at ? new Date(redisRow.last_used_at) : null,
expiresAt: redisRow.expires_at ? new Date(redisRow.expires_at) : null,
})
}
/**
* Find a token for a user by the token id
*/
async find(user: InstanceType<TokenableModel>, identifier: string | number | BigInt) {
this.#ensureIsPersisted(user)
const tokenRow = this.#parseToken(
await this.#getRedisConnection().get(`${this.type}:${identifier}`)
)
if (!tokenRow) {
return null
}
return this.redisRowToAccessToken(tokenRow)
}
/**
* Delete a token by its id
*/
async delete(
user: InstanceType<TokenableModel>,
identifier: string | number | BigInt
): Promise<number> {
this.#ensureIsPersisted(user)
const affectedRows = await this.#getRedisConnection().del(`${this.type}:${identifier}`)
return affectedRows
}
// /**
// * Returns all the tokens a given user
// */
// async all(user: InstanceType<TokenableModel>) {
// this.#ensureIsPersisted(user)
// const queryClient = await this.getDb()
// const dbRows = await queryClient
// .query<AccessTokenRedisPersisted>()
// .from(this.redisConnection)
// .where({ tokenable_id: user.$primaryKeyValue, type: this.type })
// .ifDialect('postgres', (query) => {
// query.orderBy([
// {
// column: 'last_used_at',
// order: 'desc',
// nulls: 'last',
// },
// ])
// })
// .unlessDialect('postgres', (query) => {
// query.orderBy([
// {
// column: 'last_used_at',
// order: 'asc',
// nulls: 'last',
// },
// ])
// })
// .orderBy('id', 'desc')
// .exec()
// return dbRows.map((dbRow) => {
// return this.redisRowToAccessToken(dbRow)
// })
// }
/**
* Verifies a publicly shared access token and returns an
* access token for it.
*
* Returns null when unable to verify the token or find it
* inside the storage
*/
async verify(tokenValue: Secret<string>) {
const decodedToken = AccessToken.decode(this.prefix, tokenValue.release())
if (!decodedToken) {
return null
}
const tokenRow = this.#parseToken(
await this.#getRedisConnection().get(`${this.type}:${decodedToken.identifier}`)
)
if (!tokenRow) {
return null
}
/**
* Update last time the token is used
*/
tokenRow.last_used_at = new Date().valueOf()
/**
* Insert data to redis.
*/
if (tokenRow.expires_at) {
const ttl = await this.#getRedisConnection().ttl(`${this.type}:${decodedToken.identifier}`)
await this.#getRedisConnection().setex(
`${this.type}:${decodedToken.identifier}`,
ttl,
JSON.stringify(tokenRow)
)
} else {
await this.#getRedisConnection().set(
`${this.type}:${decodedToken.identifier}`,
JSON.stringify(tokenRow)
)
}
/**
* Convert to access token instance
*/
const accessToken = this.redisRowToAccessToken(tokenRow)
/**
* Ensure the token secret matches the token hash
*/
if (!accessToken.verify(decodedToken.secret) || accessToken.isExpired()) {
return null
}
return accessToken
}
} Within the model, use it in the same way as the database provider, but defining the redisConnection: static redisToken = RedisAccessTokensProvider.forModel(DbLucidModel, {
redisConnection: 'main',
...otherOptions
}) |
Beta Was this translation helpful? Give feedback.
Yes, it is planned to have a Redis provider for the same