Skip to content
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

feat(rsc-auth): Implement getRoles function in auth mw & update default ServerAuthState #10656

Merged
merged 29 commits into from
May 31, 2024

Conversation

dac09
Copy link
Contributor

@dac09 dac09 commented May 21, 2024

  • Implement extractRoles function in supabase and dbAuth middleware
  • Updates default serverAuthState to contain roles
  • Make cookieHeader a required attribute
  • Introduces new clear() function to remove auth state - just syntax sugar

Outstanding

  • Add test for supa extractRoles
  • Add test for dbAuth extractRoles
  • Add test for serverAuth clear
  • Update invoke and how we set the default serverAuthState

Example usage

// In entry.server.tsx
export const registerMiddleware = () => {
  // This actually returns [dbAuthMiddleware, '*']
  const authMw = initDbAuthMiddleware({
    dbAuthHandler,
    getCurrentUser,
    extractRoles: (decoded) => {
      return decoded.currentUser.roles || []
    }
  })

  return [authMw]
}

dac09 added 7 commits May 21, 2024 19:52
Update default serverAuthState to contain roles
Make cookieHeader a required attribute
…-role-authmw

* 'main' of github.com:redwoodjs/redwood:
  feat(auth-middleware): Return a Tuple with Route pattern configuration when creating dbAuth middleware (redwoodjs#10642)
@dac09 dac09 changed the title feat(rsc-auth): Implement extractRoles function in auth mw feat(rsc-auth): Implement extractRoles function in auth mw & update default ServerAuthState May 21, 2024
@dac09 dac09 force-pushed the feat/extract-role-authmw branch from f68acec to 5f47249 Compare May 22, 2024 11:28
@dac09 dac09 added the release:feature This PR introduces a new feature label May 22, 2024
@dac09 dac09 added this to the RSC milestone May 22, 2024
@dac09 dac09 marked this pull request as ready for review May 22, 2024 11:36
dac09 added 3 commits May 22, 2024 23:13
…-role-authmw

* 'main' of github.com:redwoodjs/redwood:
  fix(dbAuthMw): Update and fix logic related to dbAuth "verbs" and decryptionErrors (redwoodjs#10668)
  RSC: routes-auto-loader is not used for SSR anymore (redwoodjs#10672)
  chore(crwa): Remove unused jest dev dependency (redwoodjs#10673)
  RSC: rscBuildEntriesFile: Only ServerEntry and Routes needed for serverEntries (redwoodjs#10671)
  RSC: clientSsr: getServerEntryComponent() (redwoodjs#10670)
  RSC: worker: getFunctionComponent -> getRoutesComponent (redwoodjs#10669)
  RSC: kitchen-sink: Make the ReadFileServerCell output take up less space (redwoodjs#10667)
  RSC: Remove commented code related to prefixToRemove transform() (redwoodjs#10666)
  RSC Client Router (redwoodjs#10557)
  RSC: Add 'use client' to remaining client cells in kitchen-sink (redwoodjs#10665)
  RSC: vite auto-loader: Spell out 'path' and other chores (redwoodjs#10662)
  fix(cli): Handle case for no arguments for verbose baremetal deploy  (redwoodjs#10663)
  RSC: kitchen-sink: Make it more clear where layout ends and main content starts (redwoodjs#10661)
  RSC: Make the kitchen-sink smoke-test more robust/resilient (redwoodjs#10660)
  RSC: Source format of EmptyUsersCell in kitchen-sink (redwoodjs#10658)
  RSC: Add 'use client' to all client cells in kitchen-sink (redwoodjs#10659)
  chore(__fixtures__): Follow-up: Make test projects match newer CRWA template (redwoodjs#10657)
  feat: Reworks RSC server entries and route manifest building to derive from routes and include if route info related to authentication (redwoodjs#10572)
  chore(__fixtures__): Make test projects match newer CRWA template (redwoodjs#10655)
…nto feat/extract-role-authmw

* 'feat/extract-role-authmw' of github.com:dac09/redwood:
@dac09 dac09 marked this pull request as draft May 22, 2024 16:26
@dac09
Copy link
Contributor Author

dac09 commented May 22, 2024

✋ HODL merge till we've gone through Server Router changes.

@dthyresson dthyresson self-requested a review May 22, 2024 17:09
@@ -68,6 +70,8 @@ const createSupabaseAuthMiddleware = ({
isAuthenticated: !!currentUser,
hasError: false,
userMetadata: userMetadata || currentUser,
cookieHeader,
roles: extractRoles ? extractRoles(decoded) : [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am trying to think of a case where the decoded data from the authDecoder is needed to get roles -- and not just the currentUser.

I guess one doesn't have to set roles inside getCurrentUser but typically for the auth providers we do.

Though this does give some flexibility to define roles outside the currentUser .... which might make ABAC permission easier in the future. Ie - check a permission file/config or lookup db --- for if the username starts with admin , then then get the admin role.

Think will have to use it a bit and get the feel for it, but makes sense and can see ow it will work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for example, if you add roles in supabase user_metadata, it doesn't come through as currentUser.roles!

@dac09
Copy link
Contributor Author

dac09 commented May 25, 2024

Hey DT, so I lookedup up types in the auth providers, and here's my notes on it. It just validates that we definitely need this getRoles function, but also that we could potentially have a default implementation for each auth provider (if no roles, just return [] like now)

Supabase user

Roles in user.role, but I don't think that's the one we want to use. That's the role supabase uses I believe.

In the examples I looked through with you it was in user.app_metadata.roles

export interface User {
  id: string
  app_metadata: UserAppMetadata
  user_metadata: UserMetadata
  aud: string
  confirmation_sent_at?: string
  recovery_sent_at?: string
  email_change_sent_at?: string
  new_email?: string
  new_phone?: string
  invited_at?: string
  action_link?: string
  email?: string
  phone?: string
  created_at: string
  confirmed_at?: string
  email_confirmed_at?: string
  phone_confirmed_at?: string
  last_sign_in_at?: string
  role?: string
  updated_at?: string
  identities?: UserIdentity[]
  is_anonymous?: boolean
  factors?: Factor[]
}

Firebase user

Doesn't have roles built in. Possibly can be added to UserMetadata.

export declare interface User extends UserInfo {
  readonly emailVerified: boolean;
  readonly isAnonymous: boolean;
  readonly metadata: UserMetadata;
  readonly providerData: UserInfo[];
  readonly refreshToken: string;
  readonly tenantId: string | null;
  delete(): Promise<void>;
  getIdToken(forceRefresh?: boolean): Promise<string>;
  getIdTokenResult(forceRefresh?: boolean): Promise<IdTokenResult>;
  reload(): Promise<void>;
  toJSON(): object;
}

Netlify user

Roles available in user.app_metadata.roles

export interface User {
    api: {
        _sameOrigin?: boolean | undefined;
        apiURL: string;
        defaultHeaders: {
            [header: string]: string | string[] | undefined;
        };
    };
    app_metadata: {
        provider: string;
        roles: string[];
    };
    aud: string;
    audience?: any;
    confirmed_at: string;
    created_at: string;
    updated_at: string;
    invited_at: string;
    recovery_sent_at: string;
    email: string;
    id: string;
    role: string;
    token?: Token | undefined;
    url: string;
    user_metadata: {
        avatar_url?: string;
        full_name?: string;
    } | null;
}

Clerk user

Can't see roles directly in the user object, but likely to be in user.publicMetadata ?

export interface UserResource extends ClerkResource {
    id: string;
    externalId: string | null;
    primaryEmailAddressId: string | null;
    primaryEmailAddress: EmailAddressResource | null;
    primaryPhoneNumberId: string | null;
    primaryPhoneNumber: PhoneNumberResource | null;
    primaryWeb3WalletId: string | null;
    primaryWeb3Wallet: Web3WalletResource | null;
    username: string | null;
    fullName: string | null;
    firstName: string | null;
    lastName: string | null;
    profileImageUrl: string;
    imageUrl: string;
    hasImage: boolean;
    emailAddresses: EmailAddressResource[];
    phoneNumbers: PhoneNumberResource[];
    web3Wallets: Web3WalletResource[];
    externalAccounts: ExternalAccountResource[];
    samlAccounts: SamlAccountResource[];
    organizationMemberships: OrganizationMembershipResource[];
    passwordEnabled: boolean;
    totpEnabled: boolean;
    backupCodeEnabled: boolean;
    twoFactorEnabled: boolean;
    publicMetadata: UserPublicMetadata;
    unsafeMetadata: UserUnsafeMetadata;
    lastSignInAt: Date | null;
    createOrganizationEnabled: boolean;
    deleteSelfEnabled: boolean;
    updatedAt: Date | null;
    createdAt: Date | null;
    update: (params: UpdateUserParams) => Promise<UserResource>;
    delete: () => Promise<void>;
    updatePassword: (params: UpdateUserPasswordParams) => Promise<UserResource>;
    removePassword: (params: RemoveUserPasswordParams) => Promise<UserResource>;
    createEmailAddress: (params: CreateEmailAddressParams) => Promise<EmailAddressResource>;
    createPhoneNumber: (params: CreatePhoneNumberParams) => Promise<PhoneNumberResource>;
    createWeb3Wallet: (params: CreateWeb3WalletParams) => Promise<Web3WalletResource>;
    isPrimaryIdentification: (ident: EmailAddressResource | PhoneNumberResource | Web3WalletResource) => boolean;
    getSessions: () => Promise<SessionWithActivitiesResource[]>;
    setProfileImage: (params: SetProfileImageParams) => Promise<ImageResource>;
    createExternalAccount: (params: CreateExternalAccountParams) => Promise<ExternalAccountResource>;
    getOrganizationMemberships: GetOrganizationMemberships;
    getOrganizationInvitations: (params?: GetUserOrganizationInvitationsParams) => Promise<ClerkPaginatedResponse<UserOrganizationInvitationResource>>;
    getOrganizationSuggestions: (params?: GetUserOrganizationSuggestionsParams) => Promise<ClerkPaginatedResponse<OrganizationSuggestionResource>>;
    leaveOrganization: (organizationId: string) => Promise<DeletedObjectResource>;
    createTOTP: () => Promise<TOTPResource>;
    verifyTOTP: (params: VerifyTOTPParams) => Promise<TOTPResource>;
    disableTOTP: () => Promise<DeletedObjectResource>;
    createBackupCode: () => Promise<BackupCodeResource>;
    get verifiedExternalAccounts(): ExternalAccountResource[];
    get unverifiedExternalAccounts(): ExternalAccountResource[];
    get hasVerifiedEmailAddress(): boolean;
    get hasVerifiedPhoneNumber(): boolean;
}

Auth0

No roles, but possibly comes through in scopes in the JWT?


export declare class User {
    name?: string;
    given_name?: string;
    family_name?: string;
    middle_name?: string;
    nickname?: string;
    preferred_username?: string;
    profile?: string;
    picture?: string;
    website?: string;
    email?: string;
    email_verified?: boolean;
    gender?: string;
    birthdate?: string;
    zoneinfo?: string;
    locale?: string;
    phone_number?: string;
    phone_number_verified?: boolean;
    address?: string;
    updated_at?: string;
    sub?: string;
    [key: string]: any;
}

dbAuth User

Up to the user! But in our test-project fixture this is the Prisma schema:

model User {
  id                  Int       @id @default(autoincrement())
  email               String    @unique
  hashedPassword      String
  fullName            String
  salt                String
  resetToken          String?
  resetTokenExpiresAt DateTime?
  roles               String?
  posts               Post[]
}

@dac09 dac09 marked this pull request as ready for review May 27, 2024 10:10
@dthyresson dthyresson changed the title feat(rsc-auth): Implement extractRoles function in auth mw & update default ServerAuthState feat(rsc-auth): Implement getRoles function in auth mw & update default ServerAuthState May 28, 2024
@dthyresson
Copy link
Contributor

dthyresson commented May 28, 2024

@dac09 I tested getRoles with a Supabase auth project:

  type SupabaseAppMetadata = {
    provider: string
    providers: string[]
    roles: string[]
  }

  const supabaseAuthMiddleware = initSupabaseMiddleware({
    getCurrentUser,
    getRoles: ({ app_metadata }: { app_metadata: SupabaseAppMetadata }) => {
      console.log('>>>> in getRoles', app_metadata.roles)
      return app_metadata.roles
    },
  })
  return [supabaseAuthMiddleware]
}

Typically, roles in Supabase will be stored in app_metadata (data the app but the user cannot change) and preferences (data the user may change) in user_metadata:

update AUTH.users
 set raw_app_meta_data = raw_app_meta_data || '{"roles": "admin"}',
 raw_user_meta_data = raw_user_meta_data || '{"favoriteColor": "yellow"}';

 select * from auth.users;

image

Here the role in app_metadata is admin and can confirm this role is enforced:

      <Set wrap={BlogLayout}>
        <Route path="/waterfall/{id:Int}" page={WaterfallPage} prerender name="waterfall" />
        <PrivateSet unauthenticated="login" roles={['admin']}>
          <Route path="/profile" page={ProfilePage} name="profile" />
        </PrivateSet>
        <Route path="/blog-post/{id:Int}" page={BlogPostPage} name="blogPost" prerender />
        <Route path="/contact" page={ContactUsPage} name="contactUs" />
        <PrivateSet unauthenticated="login" roles={['superuser']}>
          <Route path="/about" page={AboutPage} name="about" prerender />
        </PrivateSet>
        <Route path="/" page={HomePage} name="home" prerender />
        <Route notfound page={NotFoundPage} prerender />
      </Set>
:"req-5","res":{"statusCode":200},"responseTime":12.587541997432709,"msg":"request completed"}
>>>> in getRoles admin
>>>> in getRoles admin
>>>> in getRoles admin
transporting data {}

@dthyresson
Copy link
Contributor

Also, the dev can decided to keep the getRoles in lib/auth:

import { getCurrentUser, getRoles } from '$api/src/lib/auth'

...

export const registerMiddleware = () => {
  const supabaseAuthMiddleware = initSupabaseMiddleware({
    getCurrentUser,
    getRoles,
  })
  return [supabaseAuthMiddleware]
}

and then in

type SupabaseAppMetadata = {
  provider: string
  providers: string[]
  roles: string[]
}

export const getRoles = ({
  app_metadata,
}: {
  app_metadata: SupabaseAppMetadata
}) => {
  console.log('>>>> in getRoles', app_metadata.roles)
  return app_metadata.roles
}

which is nice.

@dthyresson
Copy link
Contributor

@dac09 Approved. Good to merge. I tested with string and array roles in a custom get roles. Working well.

@dac09 dac09 enabled auto-merge (squash) May 30, 2024 04:38
@dac09 dac09 merged commit 23025c5 into redwoodjs:main May 31, 2024
46 checks passed
@Josh-Walker-GM Josh-Walker-GM modified the milestones: RSC, v8.0.0 Sep 4, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
release:feature This PR introduces a new feature
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

4 participants