celest-dev / celest

The Flutter cloud platform
https://celest.dev
Other
232 stars 12 forks source link

RFC: Integrating external identity providers, and setting up access-based controls. #24

Open abdallahshaban557 opened 5 months ago

abdallahshaban557 commented 5 months ago

This RFC is intended to collect feedback on Celest’s developer experience around integrating external identity providers (e.g. Firebase Auth, Supabase Auth, Custom OIDC, etc.), and applying authorization rules to your Celest backend. We are sharing it as early as we can to get your feedback.

We will cover 3 key topics:

  1. Integrating external identity providers to use with your Celest backend.
  2. Applying authorization guards so that only authenticated users have access to your cloud functions.
  3. Setting up custom authorization rules, so that only users with specific roles can access features in your Celest backend.

Starting a new Celest project

You first need to create a new Celest project, follow our getting started guide to learn more.

Integrating your authentication provider

In the future, Celest will have an authentication solution to help you create and authenticate your users. However, even without that, you will have the ability to integrate existing identity providers that you currently use in your Flutter projects to authenticate users in your Celest backend.

To set up your authentication provider in your Celest project, you will need to go to the Celest folder in your Flutter project that was created for you by the Celest CLI. Create a folder called auth and then create a providers folder inside of it. Then, you will need to create a new file for each identity provider you want to use with your Celest backend. The following is an example of how you would define Firebase Auth as your identity provider.

[!NOTE]
Identity providers you use with Celest must be OIDC-compliant

// <flutter_app>/celest/auth/providers/firebase.dart
import "package:celest_backend/client.dart";

const provider = AuthProvider.custom(
  name: 'firebase',
  clientId: '...',
  issuerUrl: '...',
);

That is all the set up you need in your backend. Now, you need to go to your Flutter app, and add the following code to the entry point of your app, which is typically the main.dart file.

import 'package:flutter/material.dart';
import 'package:celest_backend/celest/client.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Initialize Firebase
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );    

  // Initialize Celest
  celest.init();

  // Pass Firebase tokens to Celest
  celest.auth.grantPermissions(
    withTokens: FirebaseAuth.instance.idTokenChanges().asyncMap(
        (user) async => CelestTokens(
          idToken: await user?.getIdToken(),
        ),
      ),
  );
}

In this code snippet, we have initialized Firebase and Celest in the Flutter app, initialized a stream that will always grab the latest firebase idToken on the device, and used grantPermissions to pass the Firebase token stream to Celest.

Now, you are ready to start adding auth guards to your Celest Functions.

Controlling access to Celest Functions

[!NOTE]
Access to Celest Functions is always deny-by-default.

You have the following options to control access to Celest Functions:

  1. Public access: Calls to your Celest Function are allowed from anyone, including the public Internet.
  2. Authenticated access: Only users authenticated by Celest are granted access your Celest Functions.
  3. Role-based access: Only users with specific roles are granted access to your Celest Functions.

Public Access

To enable public access to your Celest Function, navigate to your <flutter_app>/celest/functions/<my_api>.dart file, and add the @public directive on top of it.

import 'package:celest_backend/celest.dart';

// Enables public access to the Celest Function
@public
Future<String> sayHello(String name) {
  return 'Hello, $name!';
}

Authenticated access

To enable only authenticated users to access your Celest Function, navigate to your <flutter_app>/celest/functions/<my_api>.dart file, and add the @authenticated directive on top of it.

import 'package:celest_backend/celest.dart';

// Enables only authenticated user to access this function
@authenticated
Future<String> sayHello(String name) {
  return 'Hello, $name!';
}

Role-based access

Role-based access controls enable you to have more granular authorization for your users by mapping their permissions to roles you define. This is important to help you create functions that need to be accessed only by specific groups of users such as admins.

To set up role-based access controls, you first need to define the roles that you want to grant to your users by navigating to the <flutter_app>/celest/auth/ folder, and creating a roles.dart file. There, define as many roles as your application needs.

import 'package:celest_backend/celest.dart';

/// Assigns the role of admin to the user.
const admin = Role('admin');

In order for your users to be assigned these roles, you will need to define a hook which maps the validated ID or access token into the roles you’ve defined.

Imagine if the structure of the Firebase idToken looks as shown in the following example. The customClaims key is where the roles or special access permission information will typically be available.

{
  "aud": "my-project",
  "auth_time": 1234567890,
  "email": "example@example.com",
  "email_verified": true,
  "exp": 1234567980,
  "firebase": {
    "identities": {
      "email": [
        "example@example.com"
      ],
      "google.com": [
        "1234567890123456789012"
      ],
      "sign_in_provider": "google.com"
    },
    "sign_in_provider": "google.com"
  },
  "iat": 1234567890,
  "iss": "https://securetoken.google.com/my-project",
  "name": "Firebase User",
  "picture": "https://lh3.googleusercontent.com/...",
  // Custom permissions are available here
  "customClaims": {
    "accessLevel": "admin",
  }
}

You will need to add a definition for the onGrantPermissions hook for your identity provider you have previously created. Navigate to the <flutter_app>/celest/auth/providers/<provider.dart> file. The following code snippet has an example of parsing out the idToken from Firebase to derive custom claims, which contains the access permissions or roles a specific user has.

import "package:celest_backend/client.dart";
import "package:celest_backend/resources.dart";

const firebase = AuthProvider.custom(
  name: 'firebase',
  clientId: '...',
  issuerUrl: '...',
  // Function that is executed whenever new tokens are used in a request
  onGrantPermissions: onGrantPermissions,
);

// Defines a function to extract claims from tokens
Future<void> onGrantPermissions({
  required User user,
  Token? accessToken,
  Token? idToken, 
}) async {
  // Check if the accessLevel is set to admin in idToken
  final isAdmin = idToken!.claims.requiredMap('customClaims').optionalString('accessLevel') == 'admin';
  // Assign admin role to user
  if (isAdmin) {
    user.assignRole(Roles.admin);
  }
}

This code snippet shows an example of assigning the role of admin if the custom claim of accessLevel is available and set to admin on the idToken from Firebase. Depending on the identity provider, custom claims can be a part of either the idToken or access token. For example, Supabase provides custom claims in the access tokens. Celest supports using either for extracting claims.

Then, add the permissions on the Celest Functions you want to grant the admin role access to. The following example shows you how to set the greetAdmin function access to be allowed only for users granted the admin role.

import "package:celest_backend/client.dart";
import "package:celest_backend/resources.dart";

// Adds permission policy where only admins can access this function
@grant(
  to: [Roles.admin],
)
Future<String> greetAdmin() async {
  return "Hello, admin!";
}

With Celest, you can set up as many roles and grant access permission policies to your Celest Functions are required by your application use cases.

Applying access control to multiple Celest Functions

if you have multiple Celest Functions in your API file, and you want to add an Auth guard or access policy for all of them, you can do that by annotating your desired permissions using the library directive at the top of the API file.

// Only authenticated users can access the functions in this file
@authenticated
library;

import 'package:celest/celest.dart';

Future<String> sayHello(String name) {
  return 'Hello, $name!';
}

Future<String> sayGoodbye(String name) {
  return 'Goodbye, $name!';
}

Using role-based access controls in your Flutter app

You can also use the roles that have been applied by your Celest backend in your Flutter app. This means that you can centralize all your access control policies in your Celest backend definition, and then use them to control rendering the UI in your Flutter app.

The following code example shows you how to render your UI conditionally if your user is an admin. Using celest.auth.roles function on your frontend returns a list of roles that the signed in user has been assigned.

import "package:celest_backend/client.dart";

if (celest.auth.roles.contains(Roles.admin)) {
  return AdminWidget();
}
return Container(); 

Next steps

If there’s anything we’ve left out or things you would like to see discussed, please us know! We are also working on adding a public roadmap, creating additional RFCs for event-driven workflows, and launching cloud deployments and management.

Thank you for coming on this journey with us 💙 We are so excited to bring you these features and more very soon! 🚀

If you haven’t already, sign up for our waitlist to get the latest updates on our progress and follow us on Twitter/X where we share more insights and behind-the-scenes snippets.

drantunes commented 5 months ago

I think that initialization should be automatic, by injecting AuthProvider instance in the celest.init:

celest.init( authProvider: FirebaseAuth.instance, )

And for unsupported "drivers", there will be a custom option in the API (which will require more code on the client and server, but more flexibility from day 1).

Just a simple suggestion. What do you think?

dnys1 commented 5 months ago

Hey @drantunes! I agree this would be great, but it creates a few challenges we wanted to avoid:

  1. This would mean that the Celest client would need to take a dependency on each of the Auth providers you use. This creates some issues around choosing the right package (if there are multiple) and, in the case of Firebase, restricting the client to be Flutter-only.
  2. This would also limit your control as a developer over how tokens are refreshed and cached, and in the most general case, would require that Celest manages the caching of tokens for all providers and manages refreshes for you. It would also mean we would need control over your refresh tokens which currently we don't need.

I hear you, though, and we'll think on how we can simplify the interface even more! Thanks for your feedback 🤩

drantunes commented 5 months ago

I hear you, though, and we'll think on how we can simplify the interface even more! Thanks for your feedback 🤩

I believe this is the point: make the API very simple for greater acceptance by beginner and intermediate devs. I already imagined these problems, as there are some SDKs that require the client to have their dependency + the third dependency wrapper (e.g. celest_firebase)... this really creates problems

mariopepe commented 5 months ago

Just a subjective comment regarding "Applying access control to multiple Celest Functions":

  1. I personally would not see myself using the feature @authenticated \n library;
  2. If possible with code-gen, rather than having the annotations (@public, @authenticaed, etc.) on top of the function declaration, I'd like to have it in the function definition (see attached code)

But it could absolutely be possible that I am a minority and other users would use vastly these features above!

This is the way I generally structure my cloud functions is the following:

so for instance index.ts

export const fetchHomeTours = fetchHomeToursF;
export const fetchPublicTour = fetchPublicTourF;
export const fetchPrivateTour = fetchPrivateTourF;
...

and then fetch_home_tours.ts

export const fetchHomeToursF = functions
    .region(BusinessConstants.deployRegion)
    .https.onRequest(async (req, res) => {
        return await customFunctionStructureWithErrorHandling({
            req,
            res,
            requiresAuthentication: false, // <- whether requires auth or not
            requiresOrigin: true,
            functionBody: async () => {
                const client = await getInitializedClient();
                const result = await client.manager.findAll(HomeTour);
                const body = JSON.stringify({ data: result }, null, 2);
                await client.destroy();
                res.status(200).send(body);
            },
        });
    });
abdallahshaban557 commented 5 months ago

Got it - thank you for sharing this feedback @mariopepe. We were trying to come up with a consistent pattern for all definitions of authorization rules, and we felt the annotations were lightweight and easy enough for developers to scan their code and understand how authorization is applied on it for their Celest backend. We will keep this in mind in case we hear the same feedback from more customers!

marcglasberg commented 5 months ago

Hi, how can I bypass or simulate the authentication, when I'm running my integrations tests?

For example, suppose I want to test a chat app. I want my test to signUp users Mary and John. Then, login as Mary, and I want to test that when Mary sends a message to John, then John gets the message. In Given/When/Then format, my test would be something like this:

// Given Mary and John are users AND Mary is logged in.
var mary = User(name: "Mary", ...);
var john = User(name: "John"...);
signUp(mary, ...);
signUp(john, ...);
login(mary);

// When Mary sends John a message.
sendMessage(from: mary, to: john, text: "hello");
logout();

// Then when John logs in, he gets Mary's message.
login(john);
var msg = getAllMessages();
expect(msg.from, mary);
expect(msg.text, "hello");
logout();
dnys1 commented 5 months ago

Great question, @marcglasberg!

With Celest Auth (which we'll dive into in a future RFC), the above will be possible. This is because our Auth solution will allow a local iteration playground in the same way Celest Functions currently does. The types of simulations you're describing where you can impersonate users and impersonate roles will be possible using the generated client and the local environment created by celest start.

Food-for-thought, for now. At the moment, we're not planning to expose this for external identity providers, though let me know if that would be a blocker for you.

marcglasberg commented 5 months ago

I intend to use Celest Auth only. So, not a blocker.