Skip to content

Commit

Permalink
feat: added getValidCustomToken method and documented client-side S…
Browse files Browse the repository at this point in the history
…DK usage
  • Loading branch information
awinogrodzki committed Jul 14, 2024
1 parent 6231fd6 commit 2261ef9
Show file tree
Hide file tree
Showing 16 changed files with 323 additions and 47 deletions.
1 change: 1 addition & 0 deletions docs/pages/docs/usage/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"pages-router-api-routes": "Pages Router API Routes",
"get-server-side-props": "Usage in getServerSideProps",
"refresh-credentials": "Refreshing credentials",
"client-side-apis": "Using Client-Side APIs",
"domain-restriction": "Firebase API Key domain restriction",
"advanced-usage": "Advanced usage",
"cloud-run": "Usage in Google Cloud Run",
Expand Down
134 changes: 134 additions & 0 deletions docs/pages/docs/usage/client-side-apis.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Using Client-Side APIs

The starter example uses the [inMemoryPersistence](/~https://github.com/awinogrodzki/next-firebase-auth-edge/blob/main/examples/next-typescript-starter/app/auth/firebase.ts#L28-L30) strategy to rely solely on server-side tokens, thus avoiding any consistency issues on client-side.

This approach is recommended, but causes a few issues user may run into:

1. **Stale tokens:** In long-running client sessions, server-side tokens may no longer be valid, requiring user to refresh the page in order to get access to valid token. This may happen when user re-opens tab after 1 hour.
2. **Unauthenticated Firebase Client SDK environment:** `inMemoryPersistence` will cause `currentUser` to be `null`, when trying to access it using [client-side APIs](https://firebase.google.com/docs/auth/web/manage-users), in most cases. This prevents us from using Firebase client-side SDKs.

`next-firebase-auth-edge` provides a number of features that solves aforementioned issues, as listed below:



### Enable Refresh Token API endpoint in Auth Middleware

In long-running client-side sessions (e.g., if a user reopens a tab after 1 hour), the server-side token may be expired. This can cause access issues when using the token to validate external API calls or when using the `customToken` together with the `signInWithCustomToken` Firebase SDK function.

To fix this, `authMiddleware` can expose a special endpoint to refresh client-side tokens, if current server-side token has expired.

To enable the endpoint, define `refreshTokenPath` middleware option:

```tsx
export async function middleware(request: NextRequest) {
return authMiddleware(request, {
loginPath: "/api/login",
logoutPath: "/api/logout",
refreshTokenPath: "/api/refresh-token"
// other options...
});
}

export const config = {
// Make sure to include the path in `matcher`
matcher: ["/api/login", "/api/logout", "/api/refresh-token", "/", "/((?!_next|favicon.ico|api|.*\\.).*)"],
};
```

Calling `/api/refresh-token` does two things:

1. It checks if the current token is expired. If it is, it regenerates the token and updates the cookies by returning `Set-Cookie` header with fresh token.
2. It resolves with JSON containing valid `idToken` and `customToken`



### getValidIdToken

`getValidIdToken` works together with [refresh token endpoint](/docs/usage/client-side-apis#enable-refresh-token-api-endpoint-in-auth-middleware) to provide latest, valid id token. It can be useful if you use `token` to authorize external API calls

It requires `serverIdToken`, which is the `token` returned by [getTokens](/docs/usage/server-components#gettokens) function inside server components

The function is designed to be fast and safe to use when called multiple times. Thus, the `/api/refresh-token` endpoint will only be called if the token has expired.

Example usage:
```ts
import {getValidIdToken} from 'next-firebase-auth-edge/lib/next/client';

export async function fetchSomethingFromExternalApi(serverIdToken: string) {
const idToken = await getValidIdToken({
serverIdToken,
refreshTokenUrl: '/api/refresh-token'
});

return fetch("https://some-external-api.com/api/example", {
method: "GET",
headers: {
Authorization: `Bearer ${idToken}`,
},
})
}
```


### getValidCustomToken

`getValidCustomToken` works together with [refresh token endpoint](/docs/usage/client-side-apis#enable-refresh-token-api-endpoint-in-auth-middleware) to provide latest, valid custom token. It can be useful if you use `customToken` together with Firebase's [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) method

It requires `serverCustomToken`, which is the `customToken` returned by [getTokens](/docs/usage/server-components#gettokens) function inside server components

The function is designed to be fast and safe to use when called multiple times. Thus, the `/api/refresh-token` endpoint will only be called if the token has expired.

Example usage:
```ts
export async function signInWithServerCustomToken(
serverCustomToken: string
) {
const auth = getAuth(getFirebaseApp());

const customToken = await getValidCustomToken({
serverCustomToken,
refreshTokenUrl: '/api/refresh-token'
});

if (!customToken) {
throw new Error('Invalid custom token');
}

return signInWithCustomToken(auth, customToken);
}
```


## Using Firebase Client SDKs

Firebase Client SDK exposes [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) method that allows us to access current user using custom token.

Custom token can be obtained by calling [getTokens](/docs/usage/server-components#gettokens) function in server components

```tsx
import {signInWithCustomToken} from 'firebase/auth';
import {getValidCustomToken} from 'next-firebase-auth-edge/lib/next/client';

import {doc, getDoc, getFirestore, updateDoc, setDoc} from 'firebase/firestore';

export async function doSomethingWithFirestoreClient(
serverCustomToken: string
) {
const auth = getAuth(getFirebaseApp());

// See https://next-firebase-auth-edge-docs.vercel.app/docs/usage/client-side-apis#getvalidcustomtoken
const customToken = await getValidCustomToken({
serverCustomToken,
refreshTokenUrl: '/api/refresh-token'
});

if (!customToken) {
throw new Error('Invalid custom token');
}

const {user: firebaseUser} = await signInWithCustomToken(auth, customToken);

// Use client-side firestore instance
const db = getFirestore(getApp());
}
```
5 changes: 5 additions & 0 deletions docs/pages/docs/usage/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ If you prefer a more hands-on approach to learning, you can alternatively explor
title="Refreshing credentials"
href="/docs/usage/refresh-credentials"
/>
<Card
icon={<ChevronRightIcon />}
title="Using Client-Side APIs"
href="/docs/usage/client-side-apis"
/>
<Card
icon={<ChevronRightIcon />}
title="Advanced usage"
Expand Down
4 changes: 2 additions & 2 deletions docs/pages/docs/usage/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ You can pass this object to `NextResponse.next({ request: { headers } })` to ena
See [Modifying Request Headers in Middleware](https://vercel.com/templates/next.js/edge-functions-modify-request-header) for more information on how modified headers work

```tsx
handleValidToken: async ({ token, decodedToken }, headers) => {
handleValidToken: async ({ token, decodedToken, customToken }, headers) => {
return NextResponse.next({
request: {
headers, // Pass modified request headers to skip token verification in subsequent getTokens and getApiRequestTokens calls
Expand Down Expand Up @@ -140,7 +140,7 @@ export async function middleware(request: NextRequest) {
| serviceAccount | Optional in authenticated [Google Cloud Run](https://cloud.google.com/run) environment. Otherwise **required** | | Firebase project service account. |
| tenantId | Optional | `string` By default `undefined` | Google Cloud Platform tenant identifier. Specify if your project supports [multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication) |
| checkRevoked | Optional | `boolean` By default `false` | If true, validates the token against firebase server on each request. Unless you have a good reason, it's better not to use it. |
| handleValidToken | Optional | `(tokens: { token: string, decodedToken: DecodedIdToken }, headers: Headers) => Promise<NextResponse>` By default returns `NextResponse.next()` | Receives id and decoded tokens and should return a promise that resolves with NextResponse. Function is called with modified request `headers` as a second parameter. By passing this parameters down to `NextResponse.next({ request: { headers } })` library won't verify the token in subsequent calls to `getTokens` or `getApiRequestTokens`, which can improve response times. |
| handleValidToken | Optional | `(tokens: { token: string, decodedToken: DecodedIdToken, customToken: string }, headers: Headers) => Promise<NextResponse>` By default returns `NextResponse.next()` | Receives id and decoded tokens and should return a promise that resolves with NextResponse. Function is called with modified request `headers` as a second parameter. By passing this parameters down to `NextResponse.next({ request: { headers } })` library won't verify the token in subsequent calls to `getTokens` or `getApiRequestTokens`, which can improve response times. |
| handleInvalidToken | Optional | `(reason: InvalidTokenReason) => Promise<NextResponse>` By default returns `NextResponse.next()` | If passed, is called and returned if request has not been authenticated (either does not have credentials attached or credentials are malformed). Can be used to redirect unauthenticated users to specific page or pages. Called with `reason` as first argument, which can be one of `MISSING_CREDENTIALS`, `MISSING_REFRESH_TOKEN`, `MALFORMED_CREDENTIALS`, `INVALID_SIGNATURE`, `INVALID_CREDENTIALS`. See [handleInvalidToken](/docs/errors#handleinvalidtoken) to read description of each reason. |
| handleError | Optional | `(error: AuthError) => Promise<NextResponse>` By default returns `NextResponse.next()` | Receives an unhandled error that happened during authentication and should resolve with NextResponse. By default, in case of unhandled error during authentication, the library just allows application to render. This allows you to customize error handling. See [handleError](/docs/errors#handleerror) to read about possible errors |
| debug | Optional | `boolean` By default `false` | Provides helpful logs than can help understand authentication flow and debug issues |
23 changes: 15 additions & 8 deletions docs/pages/docs/usage/server-components.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Usage in Server Components

The library provides `getTokens` function to extract and validate user credentials. The function can be used only in `Server Components` or [API Route Handlers](/docs/usage/app-router-api-routes). It returns `null` if there are no authentication cookies or the credentials have expired. If request contains valid user credentials, the function returns an object with `token` and `decodedToken` properties. `token` is jwt-encoded string, whereas `decodedToken` is an object representing decoded `token`.
The library provides `getTokens` function to extract and validate user credentials. The function can be used only in `Server Components` or [API Route Handlers](/docs/usage/app-router-api-routes). It returns `null` if there are no authentication cookies or the credentials have expired. If request contains valid user credentials, the function returns an object with `token`, `decodedToken` and `customToken` properties. `token` is jwt-encoded string, whereas `decodedToken` is an object representing decoded `token`.

## getTokens

Expand All @@ -9,6 +9,7 @@ Example usage of `getTokens` function from `next-firebase-auth-edge`:
```tsx
import {getTokens} from 'next-firebase-auth-edge';
import {cookies, headers} from 'next/headers';
import {notFound} from 'next/navigation';

export default async function ServerComponentExample() {
const tokens = await getTokens(cookies(), {
Expand All @@ -26,15 +27,21 @@ export default async function ServerComponentExample() {
headers: headers()
});

if (!tokens) {
return notFound();
}

const {token, decodedToken, customToken} = tokens;

return (
<div style={{wordBreak: 'break-word', width: '600px'}}>
{(tokens && (
<p>
Valid token: <span>{tokens.token}</span>
<br />
<pre>{JSON.stringify(tokens.decodedToken, undefined, 2)}</pre>
</p>
)) || <p>No valid user credentials</p>}
<p>
Valid token: {token}
<br />
User email: {decodedToken.email}
<br />
Custom token: {customToken}
</p>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/next-typescript-minimal/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function middleware(request: NextRequest) {
cookieSignatureKeys: serverConfig.cookieSignatureKeys,
cookieSerializeOptions: serverConfig.cookieSerializeOptions,
serviceAccount: serverConfig.serviceAccount,
handleValidToken: async ({token, decodedToken}, headers) => {
handleValidToken: async ({token, decodedToken, customToken}, headers) => {
// Authenticated user should not be able to access /login, /register and /reset-password routes
if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
return redirectToHome(request);
Expand Down
21 changes: 21 additions & 0 deletions examples/next-typescript-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ yarn dev

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

## Configuring Firestore rules

The demo shows example usage of Firestore Client SDK

Make sure to update Firestore Database Rules of `user-counters` collection in [Firebase Console](https://console.firebase.google.com/).

The following Firestore Database Rules validates if user has access to update specific `user-counters` database entry

```
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /user-counters/{document} {
allow read, write: if request.auth.uid == resource.data.id;
}
}
}
```


## Emulator support

Library provides Firebase Authentication Emulator support
Expand Down
4 changes: 2 additions & 2 deletions examples/next-typescript-starter/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function logout() {
});
}

export async function refreshToken() {
export async function checkEmailVerification() {
const headers: Record<string, string> = {};

// This is optional. Use it if your app supports App Check – https://firebase.google.com/docs/app-check
Expand All @@ -52,7 +52,7 @@ export async function refreshToken() {
headers['X-Firebase-AppCheck'] = appCheckTokenResponse.token;
}

await fetch('/api/refresh-token', {
await fetch('/api/check-email-verification', {
method: 'GET',
headers
});
Expand Down
2 changes: 2 additions & 0 deletions examples/next-typescript-starter/app/auth/AuthContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {UserInfo} from 'firebase/auth';
import {Claims} from 'next-firebase-auth-edge/lib/auth/claims';

export interface User extends UserInfo {
idToken: string;
customToken: string;
emailVerified: boolean;
customClaims: Claims;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
'use client';

import {getToken} from '@firebase/app-check';
import * as React from 'react';
import {useAuth} from '../../auth/AuthContext';
import styles from './UserProfile.module.css';
import {useLoadingCallback} from 'react-loading-hook';
import {Button} from '../../../ui/Button';
import {useRouter} from 'next/navigation';

import {signOut} from 'firebase/auth';
import {useRouter} from 'next/navigation';
import {checkEmailVerification, logout} from '../../../api';
import {getAppCheck} from '../../../app-check';
import {Badge} from '../../../ui/Badge';
import {Button} from '../../../ui/Button';
import {ButtonGroup} from '../../../ui/ButtonGroup';
import {Card} from '../../../ui/Card';
import {Badge} from '../../../ui/Badge';
import {getToken} from '@firebase/app-check';
import {getAppCheck} from '../../../app-check';
import {useAuth} from '../../auth/AuthContext';
import {getFirebaseAuth} from '../../auth/firebase';
import {logout, refreshToken} from '../../../api';
import styles from './UserProfile.module.css';
import {incrementCounterUsingClientFirestore} from './user-counters';

interface UserProfileProps {
count: number;
Expand Down Expand Up @@ -82,8 +84,19 @@ export function UserProfile({count, incrementCounter}: UserProfileProps) {
router.refresh();
});

const [handleIncrementCounterClient, isIncrementCounterClientLoading] =
useLoadingCallback(async () => {
if (!user) {
return;
}

await incrementCounterUsingClientFirestore(user.customToken);

router.refresh();
});

const [handleReCheck, isReCheckLoading] = useLoadingCallback(async () => {
await refreshToken();
await checkEmailVerification();
router.refresh();
});

Expand All @@ -94,6 +107,11 @@ export function UserProfile({count, incrementCounter}: UserProfileProps) {
return null;
}

const isIncrementLoading =
isIncrementCounterApiLoading ||
isIncrementCounterActionPending ||
isIncrementCounterClientLoading;

return (
<div className={styles.container}>
<Card className={styles.section}>
Expand Down Expand Up @@ -157,22 +175,25 @@ export function UserProfile({count, incrementCounter}: UserProfileProps) {
<ButtonGroup>
<Button
loading={isIncrementCounterApiLoading}
disabled={
isIncrementCounterApiLoading || isIncrementCounterActionPending
}
disabled={isIncrementLoading}
onClick={handleIncrementCounterApi}
>
Update counter w/ api endpoint
</Button>
<Button
loading={isIncrementCounterActionPending}
disabled={
isIncrementCounterActionPending || isIncrementCounterApiLoading
}
disabled={isIncrementLoading}
onClick={() => startTransition(() => incrementCounter())}
>
Update counter w/ server action
</Button>
<Button
loading={isIncrementCounterClientLoading}
disabled={isIncrementLoading}
onClick={handleIncrementCounterClient}
>
Update counter w/ client firestore sdk
</Button>
</ButtonGroup>
</Card>
</div>
Expand Down
Loading

0 comments on commit 2261ef9

Please sign in to comment.