I'm still trying to get my head around the differe...
# general
n
I'm still trying to get my head around the different types of auth in a SST context having only used basic features of Cognito in the past. I'm curious to know how people are arranging their API stacks when they want to offer different roles/permissions. Say I wanted to restrict a POST endpoint to admin users but allow less privileged (but still authenticated) users access to the GET endpoint. Is this even possible with Cognito? Would that be a different authoriser on the API route?
t
my opinion here is subjective but imo cognito is just going to continue to give you hell when trying to do things like this
with most AWS services, it's worth the upfront pain to learn something that's confusing but cognito really is not worth it in the end
at this point I do all my authorization in my own codebase with middleware patterns
n
Hmm, thanks for your honesty. You'll probably see from my other help requests that I've been playing with Auth0 both federated and JWT. I'm not too keen on Auth0 either, for a few different reasons. You put in another thread about UserFront which I'm also looking at. How does that work with the API Gateway HttpJwtAuthorizer? And the same question applies, how with SST can I apply different permissions to different endpoints?
t
I also feel that the api gateway authorizers provide little benefit. They add another step of latency when you can just do a check in your function code
It's not fully following the "serverless way" but I personally just write assertions in my application code. I need to refine this to make it a public library but basically when something enters the API I do an authentication check on the headers and call something like this
Copy code
createContext({
  type: "user",
  properties: { id: "id" }
})
I have various contexts since it might be a user authenticating or an api key or an internal service. Then in my code before I do any work I'll do something like
Copy code
context().assert.can("action")
Which will throw an exception if the current context cannot do the action. It's decoupled from the "who" and just specifies the "what" and these assertions can be implemented differently for different context types
I also use this for multi-tenant stuff
This is also decoupled from any specific API endpoints and while I understand the desire to do authorization at the API level since it's easy, I find you often have to lower checks into a deeper place since you can't always tell if someone is allowed to do something just by the api route
but this approach allows you to stilld o that at first and move it deeper later
n
This is really useful, thank you. Basically you're saying that API level would just be authentication and then within the functions check for authorisation. Let me know when you make that library available 😄
t
yep! yeah we're definitely going to put some time into refining this pattern and release it
n
So, back to UserFront.... Can you point me in the direction of how I can use this for API authentication?
t
I would just follow their guide and import the libraries into my function, skip cognito all together
userfront is just one option, there's a couple you may want to poke around with
n
How would I setup the API stack in this case though. This is what I've done (following the examples) for Auth0.
Copy code
const api = new Api(stack, 'Api', {
      authorizers: {
         jwt: {
            type: 'jwt',
            cdk: {
               authorizer: new apigAuthorizers.HttpJwtAuthorizer('Authorizer', '<https://dev-8rchfy1d.us.auth0.com/>', {
                  jwtAudience: ['<https://aw6lgqy70i.execute-api.us-east-1.amazonaws.com>'],
               }),
            },
         },
      },
      defaults: {
         authorizer: 'jwt',
      },
      routes: {
         'GET /private': 'functions/private.handler',
         'GET /public': {
            function: 'functions/public.handler',
            authorizer: 'none',
         },
      },
   });
I'm not familiar enough with CDK to know what needs to be done here.
t
You wouldn't do anything in CDK, you'd just drop the authorizers section and do the check in your function
n
Ahh, I get you. So in terms of the SST stack everything would be public.
t
yeah exactly
n
Thanks for your help. I'll let you get back to some actual work now 😆
t
this is my actual work!
k
@thdxr While I also see the benefit of your approach, there is 1 potential concern of having all API routes public. I've seen a lot of scans happening in some cases in the past and using throttling is probably a good protection to consider, especially when everything is open. In the past I used my own custom authorizer function (which which retrieving allowed things from a role access db + linked these directly to the issued access token). Then I'd check the token permissions again for each of the critical actions (which would not only verify the use of the route, but also permissions of the object being operated on). It seems a bit similar to your context levels though.
t
yeah that makes sense, dealing with API limits is an issue
what you're describing sounds similar, a discrete example I can think of is imagine you have a
/user/id/photos
endpoint that should only be accessed by the user's friends. The logic for whether someone can read that information goes beyond a role or a basic check you can do at the API level (since you wouldn't load the whole list of friends into the jwt) and needs to happen at a deeper level
so while api level authorization can be nice and clean and declarative, it's usually not enough to rely on 100%
f
Just to join the party… I’ve tried using custom authorizer for APIs, and like @thdxr suggested, I ended up make deeper calls, and have route specific permission logic, ie.
Copy code
// authorizer.ts
if (route.path.startsWith("/user/id/")) {
  assertFriendPermission(userId, friendId);
}
else if (route.path.startsWith("/public")) {
  // always grant permission
}
else {
  assertUserPermission(userId);
}
If we forget about the cold start at all, I actually prefer using the custom authorizer. And even glueing a lot of Lambdas together. It’s not about best practice, but more about how to best leverage the other AWS (and non AWS) toolings.
If we take it to the “extreme” and have each line of code being a separate Lambda function (🤯), u automatically can view logs and metrics for each line of code; u can view X-Ray trace for each line of code; etc.
n
I've been thinking about this approach overnight and I'm curious as to how this affects performance and billing. My understanding isn't great in this area but is there not a situation where API gateway is used and a Lambda invoked just to get to a point to say that the caller isn't authenticated? Is there a hybrid approach where the frontend app (not the user) is authenticated at the API level before it goes any further. This would restrict any unwanted traffic on the API.
t
Yeah if you don't use a custom authorizer than it's entirely managed so there's no lambda to invoke. But if you do need custom logic it gets invoked on every request. ApiG does have a seperate throttling config if you're worried about bad actors
I also have the opposite feeling as Frank. The more a single request is broken up I feel like it's harder to get observability since your logs are more distributed. I know AWS has tracing IDs to try to tie stuff together but it's easier for me to look at a single log
n
As with most things, the more you look into it the deeper you can go to cater for every conceivable scenario. I'm going to keep it 'simple' for now. Currently looking at doing a custom authoriser for verifying a UserFront issued JWT. This is all still very new for me and I haven't researched this fully but is it possible to pass in additional parameters into the custom authoriser function? I'm thinking this could be a neat way to pass in route specific roles within the API construct itself.
t
You could pass that through as an env variable right?
Sometimes I use a pattern where I define a JSON object in my stacks code and stringify it into an environment variable
n
I was thinking something like this
Copy code
authorizers: {
      myAuthorizer: {
        type: "lambda",
        function: new Function(this, "Authorizer", {
          handler: "src/authorizer.main",
        }),
        resultsCacheTtl: "30 seconds",
      },
    },
routes: {
      'GET /private': {
        function: 'functions/private.handler',
      },
      'GET /public': {
        function: 'functions/public.handler',
        authorizer: 'none',
      },
      'GET /admin': {
        function: 'functions/admin.handler',
        role: 'admin',
      },
    },
t
Oh you meant per route hm
only way I can think of is to build a separate map per route and pass it in as an env to the auth function. Not ideal but not sure how else to do it
n
The other way that I can see is to have a separate authoriser for each route that has a different set of privileges... privateAuthorizer, adminAuthorizer etc.. But there's going to be a lot of repeated code here and doesn't offer much flexibility when multiple roles might be required.
k
@Neil Balcombe https://www.alexdebrie.com/posts/lambda-custom-authorizers/ discusses some scenarios to consider if you need a custom approach
In case you do, you could also come up with an auth logic, that get's role information from a DB (e.g. Dynamo) https://cloudonaut.io/api-gateway-custom-authorization-with-lambda-dynamodb-and-cloudformation/
n
Funnily enough, already working my way through that article.
k
😜
j
For what it's worth, I ended up doing a CustomAuthorizer that basically checks the JWT and then get the list of roles or permissions for that user from the database (or if you don't need anything granular, you can also just use cognito groups) and then pass that onto the lambda function handler. Then the handler just has a middleware/wrapper that checks against an array of permissions to make sure it's allowed to access that endpoint. The only issue I've found is because the custom auth response is cached, if you ever change someone's permissions, it may take a few extra minutes before it starts to work, but that's fine in most of my use cases anyways.