10000 Allow signing in users directly through the auth token guard by jeppester Β· Pull Request #251 Β· adonisjs/auth Β· GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Allow signing in users directly through the auth token guard #251

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 1, 2025

Conversation

jeppester
Copy link
Contributor
@jeppester jeppester commented Feb 20, 2025

πŸ”— Linked issue

I jumped right to the "making a PR"-step, I hope that it is okay? Otherwise I'll create an issue.

❓ Type of change

  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • πŸ‘Œ Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

πŸ“š Description

Allow signing in users directly through the auth token guard, like it is possible to do with the session guard.

This makes it a lot simpler to use a non-lucid user provider for the access token guard, as otherwise controllers have to use "authenticateAsClient" - which is meant for test purposes, or the token creation logic would have to be extracted to a separate service/provider, which would then have to be made available to the controllers.

We hit this issues as we were building a kysely token user provider for our app project. To our surprise we could not sign in a user through the auth guard, like we are used to for session guard.

I'd like to also add a logout-method to the guard, but adding that means extending the AccessTokensUserProviderContract (with an invalidateToken-method), which seems like a slightly bigger change. So I started with the part that creates the token.

πŸ“ Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@thetutlage
Copy link
Member

Hello @jeppester

I am a little confused. The login method you added is an alias for the createToken method. How does this align with what you have described in the PR description?

Sorry, haven't been able to understand the motivation for the PR :)

@jeppester
Copy link
Contributor Author

I am a little confused. The login method you added is an alias for the createToken method. How does this align with what you have described in the PR description?

Userprovider is a private attribute on the auth guard. So there is - unless I overlooked something - no way to call "createToken" from a controller.

When using lucid that is not an issue because that functionality is implemented by adding a static tokenProvider to the user model.

But since we don't have a model layer, we have no meaningful place to add such functionality, and being able to call login (or createToken if you prefer that) directly from the controller is honestly much more convenient.

@jeppester
Copy link
Contributor Author
jeppester commented Feb 24, 2025

To clarify a bit.

In our project we have a kysely auth provider that looks like this:

import type { Users } from '../../database/types.js'

import { Selectable } from 'kysely'
import { type Secret } from '@adonisjs/core/helpers'
import { symbols } from '@adonisjs/auth'
import {
  AccessTokensGuardUser,
  AccessTokensUserProviderContract,
} from '@adonisjs/auth/types/access_tokens'
import { AccessToken } from '@adonisjs/auth/access_tokens'
import { db } from '#services/db'

export type AuthUser = Pick<Selectable<Users>, 'id' | 'name'>

type DbToken = {
  id: string
  userId: string
  hash: string
  createdAt: Date
  updatedAt: Date
  lastUsedAt: Date | null
  expiresAt: Date
}

export class KyselyAccessTokenProvider implements AccessTokensUserProviderContract<AuthUser> {
  declare [symbols.PROVIDER_REAL_USER]: AuthUser

  dbRowAccessTokenAttributes(dbToken: DbToken) {
    return {
      identifier: dbToken.id,
      tokenableId: dbToken.userId,
      type: 'access_token',
      prefix: 'oat_',
      name: '',
      hash: dbToken.hash,
      abilities: ['*'],
      createdAt: dbToken.createdAt,
      updatedAt: dbToken.updatedAt,
      lastUsedAt: dbToken.lastUsedAt,
      expiresAt: dbToken.expiresAt,
    }
  }

  async createUserForGuard(user: AuthUser): Promise<AccessTokensGuardUser<AuthUser>> {
    return {
      getId() {
        return user.id
      },
      getOriginal() {
        return user
      },
    }
  }

  async findById(identifier: string): Promise<AccessTokensGuardUser<AuthUser> | null> {
    const user = await db()
      .selectFrom('users')
      .select(['id', 'name'])
      .where('id', '=', identifier)
      .executeTakeFirst()

    /* v8 ignore next */
    if (!user) return null

    return this.createUserForGuard(user)
  }

  async createToken(
    user: AuthUser,
    _abilities?: string[],
    options?: { name?: string; expiresIn?: string | number }
  ): Promise<AccessToken> {
    const transientToken = AccessToken.createTransientToken(user.id, 64, options?.expiresIn || 3600)

    const dbToken = await db()
      .insertInto('accessTokens')
      .values({
        userId: transientToken.userId as string,
        hash: transientToken.hash,
        createdAt: new Date(),
        updatedAt: new Date(),
        lastUsedAt: null,
        expiresAt: transientToken.expiresAt!,
      })
      .returningAll()
      .executeTakeFirstOrThrow()

    return new AccessToken({
      ...this.dbRowAccessTokenAttributes(dbToken),
      secret: transientToken.secret,
    })
  }

  /**
   * Verify a token by its publicly shared value.
   */
  async verifyToken(tokenValue: Secret<string>): Promise<AccessToken | null> {
    const decodedToken = AccessToken.decode('oat_', tokenValue.release())
    if (!decodedToken) return null

    const dbToken = await db()
      .selectFrom('accessTokens')
      .selectAll()
      .where('id', '=', decodedToken.identifier)
      .executeTakeFirstOrThrow()

    if (!dbToken) return null

    // We mutate dbToken so that we can later grab all AccessToken fields from dbToken
    dbToken.updatedAt = new Date()

    // Update DB
    await db()
      .updateTable('accessTokens')
      .where('id', '=', dbToken.id)
      .set('lastUsedAt', dbToken.updatedAt)
      .executeTakeFirstOrThrow()

    /**
     * Create access token instance
     */
    const accessToken = new AccessToken(this.dbRowAccessTokenAttributes(dbToken))

    /**
     * Ensure the token secret matches the token hash
     */
    if (!accessToken.verify(decodedToken.secret) || accessToken.isExpired()) {
      return null
    }

    return accessToken
  }
}

In our controllers the only way - again unless I'm missing something - to create a token is through the authenticateAsClient-method (meant for japa), as we are not able to get hold of the private userProvider (I'm not sure I'd want to use the user provider directly anyway), e.g.:

  async signin({ auth, ... }: HttpContext) {
    ...

    if (user) {
      return await auth
        .use('token')
        .authenticateAsClient(user) // Will return { "headers": { "authorization": "bearer ..." } }
    }

    response.status(204)
  }

What we want is to instead be able to call:

  async signin({ auth, ... }: HttpContext) {
    ...

    if (user) {
      return await auth
        .use('token')
        .login(user) // Will return an AccessToken, which will be automatically formatted in a meaningful way
    }

    response.status(204)
  }

Like it is possible to do when using session auth.

Again, I could be mistaking here, so if there is already a way to call createToken on the userProvider from within a controller, feel free to fill me in πŸ˜„

@thetutlage
Copy link
Member

Just wondering, can't you use the KyselyAccessTokenProvider directly to generate the token?

The reason, I have kept it out of the AccessTokensGuard is because the guard is request specific and generating a token is not relying on request at all. Whereas with session, creating a login session needs an HTTP request.

With that said, still curious to know what you think of using KyselyAccessTokenProvider directly for generating token?

@jeppester
Copy link
Contributor Author
jeppester commented Feb 24, 2025

With that said, still curious to know what you think of using KyselyAccessTokenProvider directly for generating token?

That would work (e.g. new KyselyAccessTokenProvider().createToken(...)), but I'm not a fan of using it directly in that way. If it was supposed to be used directly, I don't see the point of having it be a private property of the Auth provider.

Also, the user provider could potentially take a bunch of options (for instance the table names, or the DB connection) in which case it would not be possible to instantiate without providing those options again, or it would have to be a singleton or provided to the DI container.

I find it much simpler that any such options can be provided as part of the auth config in config/auth.ts (like they are for the tokensUserProvider-factory function), and that they are automatically used when creating tokens in controllers, because it's the same user provider that sits underneath.

In general, as I see it, the purpose of the auth providers is to provide a neat and simple API for a certain type of authorization, to be utilized by handlers in middleware and controllers. Having to go around that API in order to sign in (and potentially out) a user seems like an unnecessary limitation to me, and also confusing, especially for those who've worked with the session auth provider.

@thetutlage
Copy link
Member
thetutlage commented Feb 26, 2025

I have no issues in exposing a function to create a token from the auth object. But, I would further clarify what the Auth layer is supposed to do, because there is a mismatch of expectations on that front.

Why session guard is able to perform and access token guard isn't?

Because login via session is an act of modifying the HTTP current request, whereas in case of access token guard you are simply creating a record in the database and has nothing to do with the HTTP request. That is a big semantic difference, because you cannot create a login session without an HTTP request, but you should be able to create tokens without an HTTP request.

Also, the user provider could potentially take a bunch of options (for instance the table names, or the DB connection)

Yes, it could and I can see how that could be a problem. But this problem can be mitigated by having separate TokensProvider that is used by the UserProvider and also in other places in your app, exactly the way we are doing in case of Lucid.

Now, I understand that creating these abstractions can feel overwhelming when the initial goal is to quickly get login up and running.


With that said, let's get this PR merged. But I thought it will be helpful to explain my stance of these abstractions.

Also, would you be up for writing an article around using Kysely for generating access tokens and authentication? I think a lot of people will find it useful and I will be happy to host it on our official blog.

@thetutlage
Copy link
Member

Oh yeah. Could we rename this method to createToken, because login may give the expression of marking some state of the user as logged-in.

@jeppester
Copy link
Contributor Author
jeppester commented Feb 26, 2025

Oh yeah. Could we rename this method to createToken, because login may give the expression of marking some state of the user as logged-in.

Great, I'll make the change as soon as I get the time.

What is you opinion about adding an invalidateToken-method as well? (which would also be forwared to the user provider), would that be stretching too much your idea of what the auth provider is responsible for? πŸ˜„

@thetutlage
Copy link
Member

What is you opinion about adding an invalidateToken-method as well? (which would also be forwared to the user provider), would that be stretching too much your idea of what the auth provider is responsible for? πŸ˜„

A quick question. What if you want to invalidate multiple tokens? Imagine, you allow users to create one or more tokens from the UI of your app and you want them to delete the selected tokens.

@jeppester
Copy link
Contributor Author

A quick question. What if you want to invalidate multiple tokens? Imagine, you allow users to create one or more tokens from the UI of your app and you want them to delete the selected tokens.

I think I'd make it so that invalidateToken on the user provider would take a tokenValue (just like verifyToken). The invalidateToken-method on the authProvider would grab the current token from the request and use that when calling the user provider - effectively ending the current session.

So a CRUD flow for tokens would need its own code for token deletion, which I think is sensible. Such a flow would work the same way as other CRUD flows, e.g. identifying the entity by id.

@thetutlage
Copy link
Member

Cool, that makes sense. So essentially we add support for creatingTokens (same as creating a session) and deleting the current token (same as invalidating the session) via the API tokens guard.

@jeppester
Copy link
Contributor Author

So essentially we add support for creatingTokens (same as creating a session) and deleting the current token (same as invalidating the session) via the API tokens guard.

Exactly. I'll update the PR as soon as I have the time to dig into it.

@jeppester jeppester marked this pull request as draft March 6, 2025 13:16
@jeppester
Copy link
Contributor Author

I changed the PR so that the sign in method is called createToken.
I also added an invalidateToken method, but I haven't added tests yet. For that reason I converted the PR to a draft for now.

Like it is possible with the session guard.

This makes it a lot simpler to use a non-lucid user provider for the access token guard.
Otherwise controllers would have to use "authenticateAsClient" - which is meant for test purposes,
or the token creation logic would have to be extracted to a separate service/provider which would then have to be made available to the controller.
@jeppester jeppester force-pushed the 9.x branch 2 times, most recently from a3dc162 to bf8b17a Compare March 7, 2025 19:24
@jeppester jeppester marked this pull request as ready for review March 7, 2025 19:24
@jeppester
Copy link
Contributor Author

@thetutlage All the added code should have coverage now, and so I believe the PR is ready for review.

I tried my best to folow the existing test conventions, but it felt a bit like i wrote each test for token invalidation three times: for the guard, the user provider, and the token provider. Let me know if you prefer it another way.

@thetutlage
Copy link
Member

Looks good. Can you please also open a PR for the docs?

@jeppester
Copy link
Contributor Author

Looks good. Can you please also open a PR for the docs?

Yes.

From a very brief look at the docs, I see two options for how to do that - both updating the token auth docs:

  1. Update the "Issuing Tokens" section to use the auth guard. This change would IMO also require moving "Configuring the guard" before "Issuing Tokens", and probably some further changes to make the updated structure flow well.
  2. Add a section about "Issuing and invalidating tokens through the auth guard"

What is your opinion? (maybe you have another idea?)

@thetutlage
Copy link
Member

I think option 2 makes more sense. We can start by explaining that if you are using tokens to perform login/logout, then you may prefer interacting with the guard directly to issue tokens and revoke the current token via which the request was authenticated.

@jeppester
Copy link
Contributor Author

adonisjs/v6-docs#203

@thetutlage thetutlage merged commit f1e1941 into adonisjs:9.x Apr 1, 2025
7 checks passed
thetutlage pushed a commit to adonisjs/v6-docs that referenced this pull request Apr 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants
0