A toy project in a non-toy framework. Observe my journey as a Phoenix veteran trying out Ash for the first time

oh, and for the UUID thing, I’d like to add some context: I don’t personally think encrypting your identifiers matters all that much, if you have good security it shouldn’t matter if I can guess at another id that might exist in your system :person_shrugging:

We chose UUIDs only because we had to choose something simple and it wasn’t going to be auto incrementing integers because not all data layers support that, but UUIDs are supported everywhere.

7 Likes

@olivermt really enjoying reading about your journey and thought process. Thanks very much for sharing. Please keep going!

6 Likes

Hiya :wave:

AshAuthentication (hereafter AA) does a lot of work to make it as drop-in as possible. Supporting a high level of customisation means that the code can sometimes look a big hairy because it has to check the strategy struct for everything that it can do. However, let’s dig into it a bit.

Strategies

When you add

authentication do
  auth0 do
    # ...
  end
end

to your resource, it generates a strategy struct that looks something like this:

iex> Example.User |> AshAuthentication.Info.strategy!(:auth0)
%AshAuthentication.Strategy.OAuth2{
  assent_strategy: Assent.Strategy.Auth0,
  auth_method: :client_secret_post,
  authorization_params: [scope: "openid profile email"],
  authorize_url: "/authorize",
  client_authentication_method: nil,
  client_id: {AshAuthentication.SecretFunction,
   [fun: &Example.User.client_id_0_generated_1279F6D78A646E71BCCE12CAB8B3B3E3/2]},
  client_secret: {AshAuthentication.SecretFunction,
   [
     fun: &Example.User.client_secret_0_generated_1279F6D78A646E71BCCE12CAB8B3B3E3/2
   ]},
  icon: :auth0,
  id_token_signed_response_alg: nil,
  id_token_ttl_seconds: nil,
  identity_relationship_name: :identities,
  identity_relationship_user_id_attribute: :user_id,
  identity_resource: false,
  name: :auth0,
  nonce: false,
  openid_configuration_uri: nil,
  openid_configuration: nil,
  private_key: nil,
  provider: :oauth2,
  redirect_uri: {AshAuthentication.SecretFunction,
   [
     fun: &Example.User.redirect_uri_0_generated_1279F6D78A646E71BCCE12CAB8B3B3E3/2
   ]},
  register_action_name: :register_with_auth0,
  registration_enabled?: true,
  resource: Example.User,
  sign_in_action_name: :sign_in_with_auth0,
  site: {AshAuthentication.SecretFunction,
   [fun: &Example.User.site_0_generated_1279F6D78A646E71BCCE12CAB8B3B3E3/2]},
  strategy_module: AshAuthentication.Strategy.Auth0,
  token_url: "/oauth/token",
  trusted_audiences: nil,
  user_url: "/userinfo"
}

(this example is taken from the dev/test examples)

It contains all the information needed for AA to figure out how to generate routes, actions and plugs for sign-in and registration. AA.Phoenix also uses it to figure out how to render sign-in and register pages for you. In the case of OAuth2-backed strategies they’re little more than links to the strategy’s request phase.

For example, if we take this strategy and pass it into AA.Strategy.routes/1 we can see that it generates the following routes which would be injected into your Phoenix router by AA.Phoenix.Router.auth_routes_for/2:

iex> Example.User |> AshAuthentication.Info.strategy!(:auth0) |> AshAuthentication.Strategy.routes()
[{"/user/auth0", :request}, {"/user/auth0/callback", :callback}]

Actions

That’s not all we need however, as the OAuth2 strategy needs there to be a create action that can register the user or a read action which can sign-in a previously registered user, depending on whether registration_enabled? is set. The actions for OAuth2 are documented here.

You can see from the strategy their names are inferred if not directly configured and AA will give you a compilation error if they are not present or are missing necessary stuff.

In the example we have a register_with_auth0 action defined as:

    create :register_with_auth0 do
      argument :user_info, :map, allow_nil?: false
      argument :oauth_tokens, :map, allow_nil?: false
      upsert? true
      upsert_identity :username

      change AshAuthentication.GenerateTokenChange
      change Example.GenericOAuth2Change
      change AshAuthentication.Strategy.OAuth2.IdentityChange
    end

Here we’re telling Ash that we want to upsert a user (ie create it if it’s not already there) and that we want to use the named identity (ie unique constraint) to manage conflicts.

The Example.GenerateOAuth2Change is used in test and simply tries using "nickname", "login" and "preferred_username" from the user_info in order to set as the username.

Tokens

AA uses JWT for a number of purposes, which are generated and validated by joken. In AA all tokens are JWTs and not just random strings - this is a big difference between AA and phx_gen_auth).

When a user registers or signs in by any strategy and tokens are enabled AA.GenerateTokenChange generates a JWT which refers to the user and the purpose for which it was generated (usually just "user", but we also use them for resets, and other things). The generated JWT is stored in the record’s metadata so that it can be returned to the success callback of the AuthController and used in whatever way you see fit.

TokenResource

The token resource stores information about tokens - but by default only stores the JTI’s of any tokens you revoke. You can change this to by setting store_all_tokens? to true in the token DSL, which effectively inverts the logic - any JTI that’s not present in the resource is considered revoked. This can be handy for features like global logout.

UserIdentity

Has two use cases:

  1. You want to have the user signed in to multiple OAuth providers at once, or
  2. You want AshAuthentication to automatically refresh an access token using a refresh token.

I can go into this more if you want.

Authentication

For OAuth2 based strategies (including OIDC) you send the user to the generated route to the :request route (ie "<wherever I mounted it in my router>/user/auth0"). The OAuth2 strategy’s request plug generates the required configuration to ask assent to generate a redirect URL, and then redirects the user to that URL.

At some point later on the user should be redirected back to the :callback route which asks assent to validate the callback and then calls either your register or sign in actions with the appropriate arguments. If everything goes to plan you’ll receive a call to your AuthController’s success/4 callback with the conn, activity (activity is a tuple of the strategy name and the request phase), user and their shiny new token (or nil). If not the failure callback will be called.

Summary

I know this is long and I feel like I’ve just scratched the surface so please ask if you’d like me to dig into any details.

13 Likes

Been on a similar journey the past few days.

25 years writing code in many languages and frameworks, working on codebases big and small, so I’m coming from the “been there done that mindset.”

I keep coming back to Elixir for side projects, and each time I look for something new to learn. This time it is Ash.

  • I have enjoyed working with resources. The DSL basics were quick to learn (or copy).
  • Ash managing migrations is nice. My one gripe about this approach (which I’ve experienced in other frameworks) is that developers tend to ignore one of the most important database features: indexes. Something about being forced to write your own migrations make you care more…
  • My project will be used by at most a few hundred orgs, so I went with the context multitenancy approach, and… it just worked. Adding a new tenant provisions a postgres schema for it. Now, what happens in prod, in the real world, will be interesting.
  • I’ll slide into the uuid debate, and throw my 2 cents in. Searching through gigs of logs for an event for a specific uuid is so much easier.
  • Even though there appears to be a TON of documentation, it rarely actually explained what I needed. The 3 example apps are fine, but as with everything in our world, they sometimes are stale or take a different approach. I will take a reference app that is always up to date over docs pretty much any day.
  • I had working auth with pow, but ripped it out for ash_authentication, password strategy. It went fine, but I am kinda scared for when I need to support other methods.
  • AshArchive adding a default filter is triggering me. After years working on a rails app that constantly needed “.unscoped” added and every new developer being confused, I never want that again. So I’ll be removing AshArchive and manually dealing with soft deletes. (And another nit is that it puts “archived_at” as the first field in a new table. ewwww :slight_smile:)
  • Past 2 days have been at the Phoenix level, working with forms, validations, and submissions. I can see why you need to wrap stuff in your own AshPhoenix.Forms struct, but the code is ugly and really, really confusing. Like, truly ugly. Maybe I am doing it wrong, but having to use prepare_params to set an owner relation on a new resource seems weird.
  • The whole actor approach is fine, but I know I am going to forget it somewhere and be open to some huge security flaw. Hard to articulate why, but having to remember to pass it always seems wrong.
  • Ecto querying using the from approach just feels so much better, but I haven’t really taken advantage of creating custom read actions and using define_for. Still just a few days in.
  • But also, same as a I saw with ActiveRecord, developers never limit their select fields with this approach. There is rarely a time when you need all fields, so you should almost be forced to say you want them all.
  • Working with policies has been surprisingly fine. I have 3 roles, and 2 user types, and my policies have been “easy” to write. Good work.

Still overall happy with Ash, and will continue to learn it over the next few weeks. (I do like that I can see how to rip it out if needed, which is actually a nice thing to say about it…)

6 Likes

AshArchive adding a default filter is triggering me. After years working on a rails app that constantly needed “.unscoped” added and every new developer being confused, I never want that again.

This one gets me too - and there is no equivalent to unscoped to bypass that default filter. Once you’ve archived something, the way we’ve used to be able to see it again is to create another (more basic) resource for the same table, that doesn’t use ash_archival. It’s not great.

The whole actor approach is fine, but I know I am going to forget it somewhere and be open to some huge security flaw. Hard to articulate why, but having to remember to pass it always seems wrong.

I think the defaults around policies will be changing in the next major version, so that it is required by default (instead of having to turn that option on).

  • The whole actor approach is fine, but I know I am going to forget it somewhere and be open to some huge security flaw. Hard to articulate why, but having to remember to pass it always seems wrong.

For this, I’d be curious to hear what alternatives you’d prefer. It’s definitely an issue, but I think it’s the lesser of two evils. We have process dictionary features for storing the actor, but it’s actually a foot gun and we’re going to remove it in 3.0 due to he many issues it causes.

Keep your passing around the actor approach. It’s fine in reality.

But, and this is a complete gut reaction, with very little thought, maybe something like a credo check that goes through all Ash Resources and matches against some SaaS Best Practice checks. For example, it checks if policies are enabled on a resource, and if not, you need to specifically annotate when you really really don’t want them. Another might be, “Hey, you aren’t filtering on “actor” in this action.”

What is the dominant use case that people are using Ash for? If 80% are building SaaS apps, then target them with example apps and better functionality for their needs.

Ah, yeah I see what you mean. If you use this in your api:

authorization do
  authorize :by_default
end

it will always assume you want to run policies. That coupled with the fact that if no policy applies to a request it will fail, should be a decent layer of protection against this kind of thing.

What is the dominant use case that people are using Ash for? If 80% are building SaaS apps, then target them with example apps and better functionality for their needs.

I completely agree. I’m currently focused on some very important core functionality (bulk updates/destroys and atomic actions), and once those are done, I’ll begin the 3.0 push. 3.0 will be a few things:

  1. some (relatively easy to migrate) breaking changes that have been stacking up.
  2. various housekeeping tasks
  3. A major shift to focus on developer experience, from documentation, examples, cookbooks, better error messages & stack traces, things like that.
4 Likes