Use Auth0 with your app
In this guide you will learn how to set up an Encore auth handler that makes use of Auth0 in order to add a seamless signup and login experience to your web app.
For all the code and instructions of how to clone and run this example locally, see the Auth0 Example in our examples repo.
Communicate with Auth0
In your Encore app, install two modules:
$ go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2
Create a folder and naming it auth
, this is where our authentication related backend code will live.
Next, let's set up the Auth0 Authenticator
that will be used by our auth handler. The Authenticator
has a method to configure and return OAuth2 and oidc clients, and another one to verify an ID Token.
Create auth/authenticator.go
and paste the following:
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encore.dev/config"
"errors"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Auth0Config struct {
ClientID config.String
Domain config.String
CallbackURL config.String
LogoutURL config.String
}
var cfg = config.Load[*Auth0Config]()
var secrets struct {
Auth0ClientSecret string
}
// Authenticator is used to authenticate our users.
type Authenticator struct {
*oidc.Provider
oauth2.Config
}
// New instantiates the *Authenticator.
func New() (*Authenticator, error) {
provider, err := oidc.NewProvider(
context.Background(),
"https://"+cfg.Domain()+"/",
)
if err != nil {
return nil, err
}
conf := oauth2.Config{
ClientID: cfg.ClientID(),
ClientSecret: secrets.Auth0ClientSecret,
RedirectURL: cfg.CallbackURL(),
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return &Authenticator{
Provider: provider,
Config: conf,
}, nil
}
// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token field in oauth2 token")
}
oidcConfig := &oidc.Config{
ClientID: a.ClientID,
}
return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
func generateRandomState() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
state := base64.StdEncoding.EncodeToString(b)
return state, nil
}
Set up the auth handler
It's time to define your auth handler and the endpoints needed for the login and logout flow.
Create the auth/auth.go
file and paste the following:
package auth
import (
"context"
"net/url"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"github.com/coreos/go-oidc/v3/oidc"
)
// Service struct definition.
// Learn more: encore.dev/docs/primitives/services-and-apis/service-structs
//
//encore:service
type Service struct {
auth *Authenticator
}
// initService is automatically called by Encore when the service starts up.
func initService() (*Service, error) {
authenticator, err := New()
if err != nil {
return nil, err
}
return &Service{auth: authenticator}, nil
}
type LoginResponse struct {
State string `json:"state"`
AuthCodeURL string `json:"auth_code_url"`
}
//encore:api public method=POST path=/auth/login
func (s *Service) Login(ctx context.Context) (*LoginResponse, error) {
state, err := generateRandomState()
if err != nil {
return nil, &errs.Error{
Code: errs.Internal,
Message: err.Error(),
}
}
return &LoginResponse{
State: state,
// add the audience to the auth code url
AuthCodeURL: s.auth.AuthCodeURL(state),
}, nil
}
type CallbackRequest struct {
Code string `json:"code"`
}
type CallbackResponse struct {
Token string `json:"token"`
}
//encore:api public method=POST path=/auth/callback
func (s *Service) Callback(
ctx context.Context,
req *CallbackRequest,
) (*CallbackResponse, error) {
// Exchange an authorization code for a token.
token, err := s.auth.Exchange(ctx, req.Code)
if err != nil {
return nil, &errs.Error{
Code: errs.PermissionDenied,
Message: "Failed to convert an authorization code into a token.",
}
}
idToken, err := s.auth.VerifyIDToken(ctx, token)
if err != nil {
return nil, &errs.Error{
Code: errs.Internal,
Message: "Failed to verify ID Token.",
}
}
var profile map[string]interface{}
if err := idToken.Claims(&profile); err != nil {
return nil, &errs.Error{
Code: errs.Internal,
Message: err.Error(),
}
}
return &CallbackResponse{
Token: token.Extra("id_token").(string),
}, nil
}
type LogoutResponse struct {
RedirectURL string `json:"redirect_url"`
}
//encore:api public method=GET path=/auth/logout
func (s *Service) Logout(ctx context.Context) (*LogoutResponse, error) {
logoutUrl, err := url.Parse("https://" + cfg.Domain() + "/v2/logout")
if err != nil {
return nil, &errs.Error{
Code: errs.Internal,
Message: err.Error(),
}
}
returnTo, err := url.Parse(cfg.LogoutURL())
if err != nil {
return nil, &errs.Error{
Code: errs.Internal,
Message: err.Error(),
}
}
parameters := url.Values{}
parameters.Add("returnTo", returnTo.String())
parameters.Add("client_id", cfg.ClientID())
logoutUrl.RawQuery = parameters.Encode()
return &LogoutResponse{
RedirectURL: logoutUrl.String(),
}, nil
}
type ProfileData struct {
Email string `json:"email"`
Picture string `json:"picture"`
}
// The `encore:authhandler` annotation tells Encore to run this function for all
// incoming API call that requires authentication.
// Learn more: encore.dev/docs/develop/auth#the-auth-handler
//
//encore:authhandler
func (s *Service) AuthHandler(
ctx context.Context,
token string,
) (auth.UID, *ProfileData, error) {
oidcConfig := &oidc.Config{
ClientID: s.auth.ClientID,
}
t, err := s.auth.Verifier(oidcConfig).Verify(ctx, token)
if err != nil {
return "", nil, &errs.Error{
Code: errs.Unauthenticated,
Message: "invalid token",
}
}
var profile map[string]interface{}
if err := t.Claims(&profile); err != nil {
return "", nil, &errs.Error{
Code: errs.Internal,
Message: err.Error(),
}
}
// Extract profile data returned from the identity provider.
// auth0.com/docs/manage-users/user-accounts/user-profiles/user-profile-structure
profileData := &ProfileData{
Email: profile["email"].(string),
Picture: profile["picture"].(string),
}
return auth.UID(profile["sub"].(string)), profileData, nil
}
// Endpoints annotated with `auth` are public and requires authentication
// Learn more: encore.dev/docs/primitives/apis#access-controls
//
//encore:api auth method=GET path=/profile
func GetProfile(ctx context.Context) (*ProfileData, error) {
return auth.Data().(*ProfileData), nil
}
Auth0 settings
The Authenticator
class requires some values that are specific your Auth0 application, namely the ClientID
, ClientSecret
, Domain
, CallbackURL
and LogoutURL
.
Create an Auth0 account if you haven't already. Then, in the Auth0 dashboard, create a new Single Page Web Applications.
Next, go to the Application Settings section. There you will find the Domain
, Client ID
, and Client Secret
that you need to communicate with Auth0.
Copy these values, we will need them shortly.
A callback URL is where Auth0 redirects the user after they have been authenticated.
Add http://localhost:3000/callback
to the Allowed Callback URLs.
You will need to add more URLs to this list when you have a production or staging environments.
The same goes for the logout URL (were the user will get redirected after logout). Add http://localhost:3000/
to the Allowed Logout URLs.
Config and secrets
Create a configuration file in the auth
service and name it auth-config.cue
. Add the following:
ClientID: "<your client_id from above>" Domain: "<your domain from above>" // An application running locally if #Meta.Environment.Type == "development" && #Meta.Environment.Cloud == "local" { CallbackURL: "http://localhost:3000/callback" LogoutURL: "http://localhost:3000/" }
Replace the values for the ClientID
and Domain
that you got from the Auth0 dashboard.
The ClientSecret
is especially sensitive and should not be hardcoded in your code/config. Instead, you should store that as an Encore secret.
From your terminal (inside your Encore app directory), run:
$ encore secret set --prod Auth0ClientSecret
Now you should do the same for the development secret. The most secure way is to set up a different Auth0 application and use that for development. Depending on your security requirements you could also use the same secret for development and production.
Once you have a client secret for development, set it similarly to before:
$ encore secret set --dev Auth0ClientSecret
That's it! Encore will run your auth handler and validate the token against Auth0.
Frontend
Now that the backend is set up, we can create a frontend application that uses the login flow.
Here's an example using React together with React Router. This example also makes use of a Encores ability to generate request clients to make the communication with our backend simple and typesafe.
App.tsxlib/auth.tscomponents/LoginStatus.tsxlib/getRequestClient.tsimport { PropsWithChildren } from "react";
import {
createBrowserRouter,
Link,
Outlet,
redirect,
RouterProvider,
useRouteError,
} from "react-router-dom";
import { Auth0Provider } from "./lib/auth";
import AdminDashboard from "./components/AdminDashboard.tsx";
import IndexPage from "./components/IndexPage.tsx";
import "./App.css";
import LoginStatus from "./components/LoginStatus.tsx";
// Application routes
const router = createBrowserRouter([
{
id: "root",
path: "/",
Component: Layout,
errorElement: (
<Layout>
<ErrorBoundary />
</Layout>
),
children: [
{
Component: Outlet,
children: [
{
index: true,
Component: IndexPage,
},
{
// Login route
path: "login",
loader: async ({ request }) => {
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const returnToURL = searchParams.get("returnTo") ?? "/";
if (Auth0Provider.isAuthenticated()) return redirect(returnToURL);
try {
const returnURL = await Auth0Provider.login(returnToURL);
return redirect(returnURL);
} catch (error) {
throw new Error("Login failed");
}
},
},
{
// Callback route, redirected to from Auth0 after login
path: "callback",
loader: async ({ request }) => {
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const state = searchParams.get("state");
const code = searchParams.get("code");
if (!state || !code) throw new Error("Login failed");
try {
const redirectURL = await Auth0Provider.validate(state, code);
return redirect(redirectURL);
} catch (error) {
throw new Error("Login failed");
}
},
},
{
// Logout route
path: "logout",
loader: async () => {
try {
const redirectURL = await Auth0Provider.logout();
return redirect(redirectURL);
} catch (error) {
throw new Error("Logout failed");
}
},
},
{
element: <Outlet />,
// Redirect to /login if not authenticated
loader: async ({ request }) => {
if (!Auth0Provider.isAuthenticated()) {
const params = new URLSearchParams();
params.set("returnTo", new URL(request.url).pathname);
return redirect("/login?" + params.toString());
}
return null;
},
// Protected routes
children: [
{
path: "admin-dashboard",
Component: AdminDashboard,
},
],
},
],
},
],
},
]);
export default function App() {
return <RouterProvider router={router} fallbackElement={<p>Loading...</p>} />;
}
function Layout({ children }: PropsWithChildren) {
return (
<div>
<header>
<nav className="nav">
<div className="navLinks">
<Link to="/">Home</Link>
<Link to="/admin-dashboard">Admin Dashboard</Link>
</div>
<LoginStatus />
</nav>
</header>
<main className="main">{children ?? <Outlet />}</main>
</div>
);
}
function ErrorBoundary() {
const error = useRouteError() as Error;
return (
<div>
<h1>Something went wrong</h1>
<p>{error.message || JSON.stringify(error)}</p>
</div>
);
}
Auth0 Social Identity Providers
Auth0 supports multiple social identity providers (like Google and GitHub) for web applications out of the box.