Series Intro

Nowadays, due to performance constraints a lot of companies are moving away from NodeJS to Go for their network and API stacks. This series is for developers interest in making the jump from Node.js to Go.

On this series, I will teach the process of building an IDP (identity provider), a project I started to create a comunity driven self-hosted opensource version of clerk.

Series Requirements

This series requires some basic knowledge of Go, as it will teach you how to structure a Go API, and how to transfer Node.js to Go skills. Before starting this series, I recommend reading the following two books to gain familiarity with the Go programming language:

  1. Go Fundamentals: an introduction to the go programming language.
  2. Network Programming with Go: an introduction to Network Programming, from network nodes and TCP to HTTP fundamentals.

Or if you're in a hurry just take a tour of Go in the official website.

Topics to cover

This series will be divided in 6 parts:

  1. Project structure and Database design: I'll lay the foundation by setting up the project structure and designing the database schema for the IDP.
  2. Local and External Authentication with JWTs: I will touch on how I implemented local authentication using JSON Web Tokens (JWTs) Bearer tokens (RFC-6750) for our Accounts (tenants) including external auth (RFC-6749).
  3. Production mailer: to send emails we will create our own queue using redis and the "net/smtp" package from the standard library.
  4. Component testing with docker-compose: I'll touch on how to ensure the reliability of our API by writing endpoint tests using Docker Compose and Go's standard net/http and testing packages.
  5. Apps and account mapping: add multiple apps support for each account.
  6. Deploying our API: We will deploy our App to a VPS (virtual private server) using coolify.

Intro

A well-organized project structure is a requirement for building maintainable and scalable APIs. In this article, we'll explore how to structure a Go API using a very common pattern in the Node.JS world, using the Model, Service, Controller (MSC) architecture.

We will leverage the following stack:

  • Fiber: Go framework inspired in express.
  • PostgreSQL with SQLC: SQLC is a Go code generator with type safety and compile-time checks for our SQL queries.
  • Redis with fiber's Redis abstraction: a caching storage abstraction for fiber using Redis.

We will also design the database schema and create the necessary migrations to set up our data layer.

Packages structure

For the structure of our code we'll levarage the go-blueprint CLI tool made by the youtuber Melky.

To start the project install and initialize the project:

$ go install github.com/melkeydev/go-blueprint@latest
$ makedir devlogs && cd devlogs
$ go-blueprint create --name idp --framework fiber --driver postgres --git commit

This will lead to an initial file structure like:

C:/Users/user/IdeProjects/devlogs/idp:
├─── cmd
│      ├─── api
│      │      main.go
├─── internal
│      ├─── database
│      │      database.go
│      ├─── server
│      │      server.go
│      │      routes.go
│      │      routes_test.go
| ...

While the name of the module will be devlogs/idp, this is a bit far of what we want, which would be github.com/your-username/devlogs/idp. So update the module name to github.com/your-username/devlogs/idp and run:

$ go mod tidy

And move the files around for a MSC (Model, Service, Controller) architecture:

C:/Users/user/IdeProjects/devlogs/idp:
├─── cmd
│      ├─── api
│      │      main.go
├─── internal
│      ├─── config
│      │      config.go
│      │      encryption.go
│      │      logger.go
│      │      oauth.go
│      │      rate_limiter.go
│      │      tokens.go
│      │      ...
│      ├─── controllers
│      │      ├─── bodies
│      │      │      common.go
│      │      │      ...
│      │      ├─── params
│      │      │      common.go
│      │      │      ...
│      │      ├─── paths
│      │      │      common.go
│      │      │      ...
│      │      auth.go
│      │      controllers.go
│      │      ...
│      ├─── exceptions
│      │      controllers.go
│      │      services.go
│      ├─── providers
│      │      ├─── cache
│      │      │      cache.go
│      │      │      ...
│      │      ├─── database
│      │      │      database.go
│      │      │      ...
│      │      ├─── mailer
│      │      │      mailer.go
│      │      │      ...
│      │      ├─── oauth
│      │      │      oauth.go
│      │      │      ...
│      │      ├─── tokens
│      │      │      tokens.go
│      │      │      ...
│      │      ├─── encryption
│      │      │      encryption.go
│      │      │      ...
│      ├─── database
│      │      database.go
│      ├─── server
│      │      ├─── routes
│      │      │      routes.go
│      │      │      ...
│      │      logger.go
│      │      server.go
│      │      routes.go
│      ├─── services
│      │      ├─── dtos
│      │      │      common.go
│      │      │      ...
│      ├─── utils
│      │      ...
| ...

Most logic will live on the internal folder, where the main structure is as follows:

  • Config: centralizes and loads all environment configurations.
  • Server: contains the Fiber instance initialization and endpoints routing
    • /routes: specifies the routes for a given controller method.
    • logger.go: builds the default configuration for the structure logger.
    • routes.go: has the main method to register all routes RegisterFiberRoutes.
    • server.go: builds a FiberServer instance with the fiber.App instance and routes router.
  • Controllers: handle incoming HTTP requests, process them using services, and return the appropriate HTTP responses.
    • /bodies: specifies the controllers bodies.
    • /params: specifies the controllers URL and Query parameters.
    • /paths: where the routes path constants are defined so they can be easily shared.
  • Services: where most of our business logic and interactions with external providers lives.
    • /dtos: where our data transfer objects are defined,
  • Providers: where we have our external providers such as Data stores, JWTs, etc.
    • /cache: Redis storage interactions.
    • /database: PostgreSQL interactions and model structs implementations.
    • /mailer: connection to our mailing queue.
    • /oauth: oauth interactions with external authentication providers.
    • /tokens: jwt signing and verifying.
    • /encryption: envelope encryption logic provider.

Configuration

As most APIs, the configuration will come from environment variables, we can load them with the os package from the standard library.

For local development we can load the environment variables from a .env file, by installing the following package:

$ go get github.com/joho/godotenv

This variables most of the time act as secrets, hence we will use a OOP style encapsulation with them where all members of configurations structs are private and immutable, while you can get their values with getters.

Our IDP will have main configurations groups apart from the base config:

  • Logger: whether debug is active and the env to chose whether we want a text or JSON handler.
  • Tokens: the private/public keys pairs and TTL for signing and verifying JWTs.
  • OAuth: collection of client ids and secrets for each of the external authentication providers.
  • Rate Limiter: specifies the max number of request withing a window that an IP can make.
  • Encryption: list of KEK (Key encryption keys) provided to the environment.

Logger

On the internal/config directory create a logger.go file with the following struct and New function builder:

package config

type LoggerConfig struct {
    isDebug     bool
    env         string
    serviceName string
}

func NewLoggerConfig(isDebug bool, env, serviceName string) LoggerConfig {
    return LoggerConfig{
        isDebug:     isDebug,
        env:         env,
        serviceName: serviceName,
    }
}

func (l *LoggerConfig) IsDebug() bool {
    return l.isDebug
}

func (l *LoggerConfig) Env() string {
    return l.env
}

func (l *LoggerConfig) ServiceName() string {
    return l.serviceName
}

NOTE: for most methods in Go it is recommended using pointer receivers. Pointers are just address pointing to the underlying memory of the struct, but for simplicity you can think of them as pass by reference instead of pass by value.

Tokens

JWT have 3 main parts that need to be provided by the environment:

  • Public Key: the key that will be used to verify the token.
  • Private Key: the key that will be used to sign the token.
  • Time to live: the TTL in seconds of the token.

And the service will have 7 different JWTs with different key pairs:

  • Access: for the access token.
  • Account Credentials: for machine to machine access.
  • Refresh: for the refresh token to refresh the access token on client to machine access.
  • Confirmation: for the email confirmation token (JWT for confirmation can be overkill, however it saves on ram memory by not saving the hashed email in cache).
  • Reset: for email resetting.
  • OAuth: for a temporary authorization header for the token exchange.
  • Two Factor: for a temporary authorization header for passing the two factor code.

Single Configuration

Create a tokens.go file on the /internal/config directory with the struct, new method and getters:

package config

type SingleJwtConfig struct {
    publicKey  string
    privateKey string
    ttlSec     int64
}

func NewSingleJwtConfig(publicKey, privateKey string, ttlSec int64) SingleJwtConfig {
    return SingleJwtConfig{
        publicKey:  publicKey,
        privateKey: privateKey,
        ttlSec:     ttlSec,
    }
}

func (s *SingleJwtConfig) PublicKey() string {
    return s.publicKey
}

func (s *SingleJwtConfig) PrivateKey() string {
    return s.privateKey
}

func (s *SingleJwtConfig) TtlSec() int64 {
    return s.ttlSec
}

Tokens Configuration

Now do the same as previously but for each token type:

package config

// ...

type TokensConfig struct {
    access             SingleJwtConfig
    accountCredentials SingleJwtConfig
    refresh            SingleJwtConfig
    confirm            SingleJwtConfig
    reset              SingleJwtConfig
    oAuth              SingleJwtConfig
    twoFA              SingleJwtConfig
}

func NewTokensConfig(
    access SingleJwtConfig,
    accountCredentials SingleJwtConfig,
    refresh SingleJwtConfig,
    confirm SingleJwtConfig,
    oAuth SingleJwtConfig,
    twoFA SingleJwtConfig,
) TokensConfig {
    return TokensConfig{
        access:      access,
        accountCredentials: accountCredentials,
        refresh:     refresh,
        confirm:     confirm,
        oAuth:       oAuth,
        twoFA:       twoFA,
    }
}

func (t *TokensConfig) Access() SingleJwtConfig {
    return t.access
}

func (t *TokensConfig) AccountCredentials() SingleJwtConfig {
    return t.accountKeys
}

func (t *TokensConfig) Refresh() SingleJwtConfig {
    return t.refresh
}

func (t *TokensConfig) Confirm() SingleJwtConfig {
    return t.confirm
}

func (t *TokensConfig) Reset() SingleJwtConfig {
    return t.reset
}

func (t *TokensConfig) OAuth() SingleJwtConfig {
    return t.oAuth
}

func (t *TokensConfig) TwoFA() SingleJwtConfig {
    return t.twoFA
}

OAuth

External authentication providers have two environment variables each:

  • Client ID: the identifier of the app on the external IDP
  • Client Secret: a secure secret to fetch the user info from the code exchange.

And we will add support for the 5 main western ones:

  • GitHub
  • Google
  • Facebook
  • Apple
  • Microsoft

Single OAuth provider configuration

Create a oauth.go file on the /internal/config directory with the struct, new method and getters:

package config

type OAuthProviderConfig struct {
    clientID     string
    clientSecret string
}

func NewOAuthProvider(clientID, clientSecret string) OAuthProviderConfig {
    return OAuthProviderConfig{
        clientID:     clientID,
        clientSecret: clientSecret,
    }
}

func (o *OAuthProviderConfig) ClientID() string {
    return o.clientID
}

func (o *OAuthProviderConfig) ClientSecret() string {
    return o.clientSecret
}

OAuth providers configuration

Create a struct, new method and getter for each provider:

package config

// ...

type OAuthProvidersConfig struct {
    gitHub    OAuthProviderConfig
    google    OAuthProviderConfig
    facebook  OAuthProviderConfig
    apple     OAuthProviderConfig
    microsoft OAuthProviderConfig
}

func NewOAuthProviders(gitHub, google, facebook, apple, microsoft OAuthProviderConfig) OAuthProvidersConfig {
    return OAuthProvidersConfig{
        gitHub:    gitHub,
        google:    google,
        facebook:  facebook,
        apple:     apple,
        microsoft: microsoft,
    }
}

func (o *OAuthProvidersConfig) GitHub() OAuthProviderConfig {
    return o.gitHub
}

func (o *OAuthProvidersConfig) Google() OAuthProviderConfig {
    return o.google
}

func (o *OAuthProvidersConfig) Facebook() OAuthProviderConfig {
    return o.facebook
}

func (o *OAuthProvidersConfig) Apple() OAuthProviderConfig {
    return o.apple
}

func (o *OAuthProvidersConfig) Microsoft() OAuthProviderConfig {
    return o.microsoft
}

Rate Limiter

The rate limiter has only two options:

  • Max: the number of maximum number of requests per window size.
  • Expiration Seconds: the window size in seconds.

Just create the rate_limiter.go file on the /internal/config directory as follows:

package config

type RateLimiterConfig struct {
    max    int64
    expSec int64
}

func NewRateLimiterConfig(max, expSec int64) RateLimiterConfig {
    return RateLimiterConfig{
        max:    max,
        expSec: expSec,
    }
}

func (r *RateLimiterConfig) Max() int64 {
    return r.max
}

func (r *RateLimiterConfig) ExpSec() int64 {
    return r.expSec
}

Encryption

Finaly to configure the KEKs for each encryption space:

  • Accounts: encryption of secrets for TOTP two factor auth.
  • Apps: encryption for the APPs private keys.
  • Users: encryption of secrets for TOTP two factor auth.

As well as old keys for easy keys rotation.

Add them to encryption.go file:

package config

import "encoding/json"

type EncryptionConfig struct {
    accountSecret string
    appSecret     string
    userSecret    string
    oldSecrets    []string
}

func NewEncryptionConfig(accountSecret, appSecret, userSecret, oldSecrets string) EncryptionConfig {
    var secretSlice []string
    if err := json.Unmarshal([]byte(oldSecrets), &secretSlice); err != nil {
        panic(err)
    }

    return EncryptionConfig{
        accountSecret: accountSecret,
        appSecret:     appSecret,
        userSecret:    userSecret,
        oldSecrets:    secretSlice,
    }
}

func (e *EncryptionConfig) AccountSecret() string {
    return e.accountSecret
}

func (e *EncryptionConfig) AppSecret() string {
    return e.appSecret
}

func (e *EncryptionConfig) UserSecret() string {
    return e.userSecret
}

func (e *EncryptionConfig) OldSecrets() []string {
    return e.oldSecrets
}

Base config

On the base config you just need to add the environment variables in an array, as well as getters for each config parameter of the struct:

package config

import (
    "log/slog"
    "os"
    "strconv"
    "strings"

    "github.com/google/uuid"
    "github.com/joho/godotenv"
)

type Config struct {
    port                 int64
    maxProcs             int64
    databaseURL          string
    redisURL             string
    frontendDomain       string
    backendDomain        string
    cookieSecret         string
    cookieName           string
    emailPubChannel      string
    encryptionSecret     string
    serviceID            uuid.UUID
    loggerConfig         LoggerConfig
    tokensConfig         TokensConfig
    oAuthProvidersConfig OAuthProvidersConfig
    rateLimiterConfig    RateLimiterConfig
    encryptionConfig     EncryptionConfig
}

func (c *Config) Port() int64 {
    return c.port
}

func (c *Config) MaxProcs() int64 {
    return c.maxProcs
}

func (c *Config) DatabaseURL() string {
    return c.databaseURL
}

func (c *Config) RedisURL() string {
    return c.redisURL
}

func (c *Config) FrontendDomain() string {
    return c.frontendDomain
}

func (c *Config) BackendDomain() string {
    return c.backendDomain
}

func (c *Config) CookieSecret() string {
    return c.cookieSecret
}

func (c *Config) CookieName() string {
    return c.cookieName
}

func (c *Config) EmailPubChannel() string {
    return c.emailPubChannel
}

func (c *Config) EncryptionSecret() string {
    return c.encryptionSecret
}

func (c *Config) ServiceID() uuid.UUID {
    return c.serviceID
}

func (c *Config) LoggerConfig() LoggerConfig {
    return c.loggerConfig
}

func (c *Config) TokensConfig() TokensConfig {
    return c.tokensConfig
}

func (c *Config) OAuthProvidersConfig() OAuthProvidersConfig {
    return c.oAuthProvidersConfig
}

func (c *Config) RateLimiterConfig() RateLimiterConfig {
    return c.rateLimiterConfig
}

func (c *Config) EncryptionConfig() EncryptionConfig {
    return c.encryptionConfig
}

var variables = [40]string{
    "PORT",
    "ENV",
    "DEBUG",
    "SERVICE_NAME",
    "SERVICE_ID",
    "MAX_PROCS",
    "DATABASE_URL",
    "REDIS_URL",
    "FRONTEND_DOMAIN",
    "BACKEND_DOMAIN",
    "COOKIE_SECRET",
    "COOKIE_NAME",
    "RATE_LIMITER_MAX",
    "RATE_LIMITER_EXP_SEC",
    "EMAIL_PUB_CHANNEL",
    "JWT_ACCESS_PUBLIC_KEY",
    "JWT_ACCESS_PRIVATE_KEY",
    "JWT_ACCESS_TTL_SEC",
    "JWT_ACCOUNT_CREDENTIALS_PUBLIC_KEY",
    "JWT_ACCOUNT_CREDENTIALS_PRIVATE_KEY",
    "JWT_ACCOUNT_CREDENTIALS_TTL_SEC",
    "JWT_REFRESH_PUBLIC_KEY",
    "JWT_REFRESH_PRIVATE_KEY",
    "JWT_REFRESH_TTL_SEC",
    "JWT_CONFIRM_PUBLIC_KEY",
    "JWT_CONFIRM_PRIVATE_KEY",
    "JWT_CONFIRM_TTL_SEC",
    "JWT_RESET_PUBLIC_KEY",
    "JWT_RESET_PRIVATE_KEY",
    "JWT_RESET_TTL_SEC",
    "JWT_OAUTH_PUBLIC_KEY",
    "JWT_OAUTH_PRIVATE_KEY",
    "JWT_OAUTH_TTL_SEC",
    "JWT_2FA_PUBLIC_KEY",
    "JWT_2FA_PRIVATE_KEY",
    "JWT_2FA_TTL_SEC",
    "ACCOUNT_SECRET",
    "APP_SECRET",
    "USER_SECRET",
    "OLD_SECRETS",
}

var optionalVariables = [17]string{
    "GITHUB_CLIENT_ID",
    "GITHUB_CLIENT_SECRET",
    "GOOGLE_CLIENT_ID",
    "GOOGLE_CLIENT_SECRET",
    "FACEBOOK_CLIENT_ID",
    "FACEBOOK_CLIENT_SECRET",
    "APPLE_CLIENT_ID",
    "APPLE_CLIENT_SECRET",
    "MICROSOFT_CLIENT_ID",
    "MICROSOFT_CLIENT_SECRET",
    "OLD_JWT_ACCESS_PUBLIC_KEY",
    "OLD_JWT_ACCOUNT_CREDENTIALS_PUBLIC_KEY",
    "OLD_JWT_REFRESH_PUBLIC_KEY",
    "OLD_JWT_CONFIRM_PUBLIC_KEY",
    "OLD_JWT_RESET_PUBLIC_KEY",
    "OLD_JWT_OAUTH_PUBLIC_KEY",
    "OLD_JWT_2FA_PUBLIC_KEY",
}

var numerics = [11]string{
    "PORT",
    "MAX_PROCS",
    "JWT_ACCESS_TTL_SEC",
    "JWT_ACCOUNT_CREDENTIALS_TTL_SEC",
    "JWT_REFRESH_TTL_SEC",
    "JWT_CONFIRM_TTL_SEC",
    "JWT_RESET_TTL_SEC",
    "JWT_OAUTH_TTL_SEC",
    "JWT_2FA_TTL_SEC",
    "RATE_LIMITER_MAX",
    "RATE_LIMITER_EXP_SEC",
}

func NewConfig(logger *slog.Logger, envPath string) Config {
    err := godotenv.Load(envPath)
    if err != nil {
        logger.Error("Error loading .env file")
    }

    variablesMap := make(map[string]string)
    for _, variable := range variables {
        value := os.Getenv(variable)
        if value == "" {
            logger.Error(variable + " is not set")
            panic(variable + " is not set")
        }
        variablesMap[variable] = value
    }

    for _, variable := range optionalVariables {
        value := os.Getenv(variable)
        variablesMap[variable] = value
    }

    intMap := make(map[string]int64)
    for _, numeric := range numerics {
        value, err := strconv.ParseInt(variablesMap[numeric], 10, 0)
        if err != nil {
            logger.Error(numeric + " is not an integer")
            panic(numeric + " is not an integer")
        }
        intMap[numeric] = value
    }

    env := variablesMap["ENV"]
    return Config{
        port:            intMap["PORT"],
        maxProcs:        intMap["MAX_PROCS"],
        databaseURL:     variablesMap["DATABASE_URL"],
        redisURL:        variablesMap["REDIS_URL"],
        frontendDomain:  variablesMap["FRONTEND_DOMAIN"],
        backendDomain:   variablesMap["BACKEND_DOMAIN"],
        cookieSecret:    variablesMap["COOKIE_SECRET"],
        cookieName:      variablesMap["COOKIE_NAME"],
        emailPubChannel: variablesMap["EMAIL_PUB_CHANNEL"],
        serviceID:       uuid.MustParse(variablesMap["SERVICE_ID"]),
        loggerConfig: NewLoggerConfig(
            strings.ToLower(variablesMap["DEBUG"]) == "true",
            env,
            variablesMap["SERVICE_NAME"],
        ),
        tokensConfig: NewTokensConfig(
            NewSingleJwtConfig(
                variablesMap["JWT_ACCESS_PUBLIC_KEY"],
                variablesMap["JWT_ACCESS_PRIVATE_KEY"],
                variablesMap["OLD_JWT_ACCESS_PUBLIC_KEY"],
                intMap["JWT_ACCESS_TTL_SEC"],
            ),
            NewSingleJwtConfig(
                variablesMap["JWT_ACCOUNT_CREDENTIALS_PUBLIC_KEY"],
                variablesMap["JWT_ACCOUNT_CREDENTIALS_PRIVATE_KEY"],
                variablesMap["OLD_JWT_ACCOUNT_CREDENTIALS_PUBLIC_KEY"],
                intMap["JWT_ACCOUNT_CREDENTIALS_TTL_SEC"],
            ),
            NewSingleJwtConfig(
                variablesMap["JWT_REFRESH_PUBLIC_KEY"],
                variablesMap["JWT_REFRESH_PRIVATE_KEY"],
                variablesMap["OLD_JWT_REFRESH_PUBLIC_KEY"],
                intMap["JWT_REFRESH_TTL_SEC"],
            ),
            NewSingleJwtConfig(
                variablesMap["JWT_CONFIRM_PUBLIC_KEY"],
                variablesMap["JWT_CONFIRM_PRIVATE_KEY"],
                variablesMap["OLD_JWT_CONFIRM_PUBLIC_KEY"],
                intMap["JWT_CONFIRM_TTL_SEC"],
            ),
            NewSingleJwtConfig(
                variablesMap["JWT_RESET_PUBLIC_KEY"],
                variablesMap["JWT_RESET_PRIVATE_KEY"],
                variablesMap["OLD_JWT_RESET_PUBLIC_KEY"],
                intMap["JWT_RESET_TTL_SEC"],
            ),
            NewSingleJwtConfig(
                variablesMap["JWT_OAUTH_PUBLIC_KEY"],
                variablesMap["JWT_OAUTH_PRIVATE_KEY"],
                variablesMap["OLD_JWT_OAUTH_PUBLIC_KEY"],
                intMap["JWT_OAUTH_TTL_SEC"],
            ),
            NewSingleJwtConfig(
                variablesMap["JWT_2FA_PUBLIC_KEY"],
                variablesMap["JWT_2FA_PRIVATE_KEY"],
                variablesMap["OLD_JWT_2FA_PUBLIC_KEY"],
                intMap["JWT_2FA_TTL_SEC"],
            ),
        ),
        oAuthProvidersConfig: NewOAuthProviders(
            NewOAuthProvider(variablesMap["GITHUB_CLIENT_ID"], variablesMap["GITHUB_CLIENT_SECRET"]),
            NewOAuthProvider(variablesMap["GOOGLE_CLIENT_ID"], variablesMap["GOOGLE_CLIENT_SECRET"]),
            NewOAuthProvider(variablesMap["FACEBOOK_CLIENT_ID"], variablesMap["FACEBOOK_CLIENT_SECRET"]),
            NewOAuthProvider(variablesMap["APPLE_CLIENT_ID"], variablesMap["APPLE_CLIENT_SECRET"]),
            NewOAuthProvider(variablesMap["MICROSOFT_CLIENT_ID"], variablesMap["MICROSOFT_CLIENT_SECRET"]),
        ),
        rateLimiterConfig: NewRateLimiterConfig(
            intMap["RATE_LIMITER_MAX"],
            intMap["RATE_LIMITER_EXP_SEC"],
        ),
        encryptionConfig: NewEncryptionConfig(
            variablesMap["ACCOUNT_SECRET"],
            variablesMap["APP_SECRET"],
            variablesMap["USER_SECRET"],
            variablesMap["OLD_SECRETS"],
        ),
    }
}

Providers

With the config done, we can start creating the providers:

  • cache: Redis cache;
  • database: PostgreSQL database;
  • mailer: Redis PubSub publisher for emails (the queue will be built on a subsquent article);
  • oauth: external authentication providers;
  • tokens: jwt signing and verifying;
  • encrytion: the provider for envolope encryption (more of an isolation of logic than a proper provider. Encryption logic should always be isolated in production)

Utilities

Observasibility of APIs is important for production debugging, so lets create an utility to build a structure logger.

To make it easy to locate the function and logic, we need to pass the package location, method, and layer. Create the utility on the root utils package. Create a logger.go file and add the following code:

package utils

import (
    "log/slog"
)

type LogLayer = string

const (
    ControllersLogLayer LogLayer = "controllers"
    ServicesLogLayer    LogLayer = "services"
    ProvidersLogLayer   LogLayer = "providers"
)

type LoggerOptions struct {
    Layer     string
    Location  string
    Method    string
    RequestID string
}

func BuildLogger(logger *slog.Logger, opts LoggerOptions) *slog.Logger {
    return logger.With(
        "layer", opts.Layer,
        "location", opts.Location,
        "method", opts.Method,
        "requestId", opts.RequestID,
    )
}

Cache

Our cache implementation will just extend the fiber Redis Storage abstraction. On the internal/providers create a /cache directory and add cache.go file with the following struct and new function:

package cache

import (
    "context"
    "log/slog"

    fiberRedis "github.com/gofiber/storage/redis/v3"
    "github.com/redis/go-redis/v9"
)

const logLayer string = "cache"

type Cache struct {
    logger  *slog.Logger
    storage *fiberRedis.Storage
}

func NewCache(logger *slog.Logger, storage *fiberRedis.Storage) *Cache {
    return &Cache{
        logger:  logger,
        storage: storage,
    }
}

With three common methods to reset the cache, get the redist client and ping the cache:

package cache

// ...

func (c *Cache) ResetCache() error {
    return c.storage.Reset()
}

func (c *Cache) Client() redis.UniversalClient {
    return c.storage.Conn()
}

func (c *Cache) Ping(ctx context.Context) error {
    return c.Client().Ping(ctx).Err()
}

Database

The database set-up is a bit more complex. We will write both our migrations and queries in SQL and use migrate to migrate our DB changes, and SQLC to generate safe Go SQL query codes.

Start by installing the necessary dependencies:

$ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
$ go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

And create a config file for SQLC as sqlc.yaml on the idp root folder:

version: "2"
sql:
  - schema: "internal/providers/database/migrations"
    queries: "internal/providers/database/queries"
    engine: "postgresql"
    gen:
      go:
        package: "database"
        out: "internal/providers/database"
        sql_package: "pgx/v5"
        emit_empty_slices: true
        overrides:
          - db_type: "timestamptz"
            go_type: "time.Time"
          - db_type: "uuid"
            go_type: "github.com/google/uuid.UUID"

Database Schema

Since we are creating an IDP we will have three main entitites:

  • Accounts (also known as tenants): the main users/admins that can create APPs that provide authentication to their own services.
  • Apps: the authentication provider configuration for a given service or services.
  • Users: users that have registered for the account's apps.

This leads to the following somewhat complex datamodel:

Database Schema

Initial Migration

With migrate installed run the following command:

$ migrate create --ext sql --dir ./internal/providers/database/migration create_initial_schema

This will generate two files:

  • YYYYMMDDHHMMSS_create_initial_schema.up.sql
  • YYYYMMDDHHMMSS_create_initial_schema.down.sql

To the up migration copy the following SQL:

CREATE TABLE "accounts" (
  "id" serial PRIMARY KEY,
  "first_name" varchar(50) NOT NULL,
  "last_name" varchar(50) NOT NULL,
  "username" varchar(109) NOT NULL,
  "email" varchar(250) NOT NULL,
  "password" text,
  "version" integer NOT NULL DEFAULT 1,
  "is_confirmed" boolean NOT NULL DEFAULT false,
  "two_factor_type" varchar(5) NOT NULL DEFAULT 'none',
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "account_totps" (
  "id" serial PRIMARY KEY,
  "account_id" integer NOT NULL,
  "url" varchar(250) NOT NULL,
  "secret" text NOT NULL,
  "dek" text NOT NULL,
  "recovery_codes" jsonb NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "account_credentials" (
  "id" serial PRIMARY KEY,
  "account_id" integer NOT NULL,
  "scopes" jsonb NOT NULL,
  "client_id" varchar(22) NOT NULL,
  "client_secret" text NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "auth_providers" (
  "id" serial PRIMARY KEY,
  "email" varchar(250) NOT NULL,
  "provider" varchar(10) NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "apps" (
  "id" serial PRIMARY KEY,
  "account_id" integer NOT NULL,
  "name" varchar(50) NOT NULL,
  "client_id" varchar(22) NOT NULL,
  "client_secret" text NOT NULL,
  "dek" text NOT NULL,
  "callback_uris" varchar(250)[] NOT NULL DEFAULT '{}',
  "logout_uris" varchar(250)[] NOT NULL DEFAULT '{}',
  "user_scopes" jsonb NOT NULL DEFAULT '{ "email": true, "name": true }',
  "app_providers" jsonb NOT NULL DEFAULT '{ "email_password": true }',
  "id_token_ttl" integer NOT NULL DEFAULT 3600,
  "jwt_crypto_suite" varchar(7) NOT NULL DEFAULT 'ES256',
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "app_keys" (
  "id" serial PRIMARY KEY,
  "app_id" integer NOT NULL,
  "account_id" integer NOT NULL,
  "name" varchar(10) NOT NULL,
  "jwt_crypto_suite" varchar(7) NOT NULL,
  "public_key" jsonb NOT NULL,
  "private_key" text NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "users" (
  "id" serial PRIMARY KEY,
  "account_id" integer NOT NULL,
  "email" varchar(250) NOT NULL,
  "password" text,
  "version" integer NOT NULL DEFAULT 1,
  "two_factor_type" varchar(5) NOT NULL DEFAULT 'none',
  "user_data" jsonb NOT NULL DEFAULT '{}',
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "user_totps" (
  "id" serial PRIMARY KEY,
  "user_id" integer NOT NULL,
  "url" varchar(250) NOT NULL,
  "secret" text NOT NULL,
  "dek" text NOT NULL,
  "recovery_codes" jsonb NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "user_auth_providers" (
  "id" serial PRIMARY KEY,
  "user_id" integer NOT NULL,
  "email" varchar(250) NOT NULL,
  "provider" varchar(10) NOT NULL,
  "account_id" integer NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now()),
  "updated_at" timestamp NOT NULL DEFAULT (now())
);

CREATE TABLE "blacklisted_tokens" (
  "id" uuid PRIMARY KEY,
  "expires_at" timestamp NOT NULL,
  "created_at" timestamp NOT NULL DEFAULT (now())
);

CREATE UNIQUE INDEX "accounts_email_uidx" ON "accounts" ("email");

CREATE UNIQUE INDEX "accounts_username_uidx" ON "accounts" ("username");

CREATE UNIQUE INDEX "accounts_totps_account_id_uidx" ON "account_totps" ("account_id");

CREATE UNIQUE INDEX "account_credentials_client_id_uidx" ON "account_credentials" ("client_id");

CREATE INDEX "account_credentials_account_id_idx" ON "account_credentials" ("account_id");

CREATE INDEX "auth_providers_email_idx" ON "auth_providers" ("email");

CREATE UNIQUE INDEX "auth_providers_email_provider_uidx" ON "auth_providers" ("email", "provider");

CREATE INDEX "apps_account_id_idx" ON "apps" ("account_id");

CREATE UNIQUE INDEX "client_id_uidx" ON "apps" ("client_id");

CREATE INDEX "app_keys_app_id_idx" ON "app_keys" ("app_id");

CREATE INDEX "app_keys_account_id_idx" ON "app_keys" ("account_id");

CREATE UNIQUE INDEX "app_keys_name_app_id_uidx" ON "app_keys" ("name", "app_id");

CREATE UNIQUE INDEX "users_account_id_email_uidx" ON "users" ("account_id", "email");

CREATE INDEX "users_account_id_idx" ON "users" ("account_id");

CREATE UNIQUE INDEX "user_totps_user_id_uidx" ON "user_totps" ("user_id");

CREATE INDEX "user_auth_provider_email_idx" ON "user_auth_providers" ("email");

CREATE INDEX "user_auth_provider_user_id_idx" ON "user_auth_providers" ("user_id");

CREATE UNIQUE INDEX "user_auth_provider_account_id_provider_uidx" ON "user_auth_providers" ("email", "account_id", "provider");

CREATE INDEX "user_auth_provider_account_id_idx" ON "user_auth_providers" ("account_id");

ALTER TABLE "account_totps" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE;

ALTER TABLE "account_credentials" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE;

ALTER TABLE "auth_providers" ADD FOREIGN KEY ("email") REFERENCES "accounts" ("email") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "apps" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE;

ALTER TABLE "app_keys" ADD FOREIGN KEY ("app_id") REFERENCES "apps" ("id") ON DELETE CASCADE;

ALTER TABLE "app_keys" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE;

ALTER TABLE "users" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE;

ALTER TABLE "user_totps" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE;

ALTER TABLE "user_auth_providers" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE;

ALTER TABLE "user_auth_providers" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE;

While on the down drop all tables if they exist:

DROP TABLE IF EXISTS "user_auth_providers";
DROP TABLE IF EXISTS "user_totps";
DROP TABLE IF EXISTS "users";
DROP TABLE IF EXISTS "app_keys";
DROP TABLE IF EXISTS "apps";
DROP TABLE IF EXISTS "auth_providers";
DROP TABLE IF EXISTS "account_credentials";
DROP TABLE IF EXISTS "account_totps";
DROP TABLE IF EXISTS "accounts";
DROP TABLE IF EXISTS "blacklisted_tokens";

SQLC Code generation

To set up the SQLC generated code we need to start by writting a query on the queries folder. Create a accounts.sql file in the queries directory and add the logic to insert an account:

-- name: CreateAccountWithPassword :one
INSERT INTO "accounts" (
    "first_name",
    "last_name",
    "username",
    "email", 
    "password"
) VALUES (
    $1, 
    $2, 
    $3,
    $4,
    $5
) RETURNING *;

SQLC knows what to return and what to call to the go method by the code comment on top of the SQL code.

Then in the terminal run the following command:

$ sqlc generate

This will generate the following files:

  • models.go
  • db.go
  • accounts.sql.go

These files are auto-generated and auto-updated when you run the sqlc generate command, hence on a new database.go create the logic to connect to the SQLC generated code:

package database

import (
    "context"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/tugascript/devlogs/idp/internal/exceptions"
)

type Database struct {
    connPool *pgxpool.Pool
    *Queries
}

func NewDatabase(connPool *pgxpool.Pool) *Database {
    return &Database{
        connPool: connPool,
        Queries:  New(connPool),
    }
}

func (d *Database) BeginTx(ctx context.Context) (*Queries, pgx.Tx, error) {
    txn, err := d.connPool.BeginTx(ctx, pgx.TxOptions{
        DeferrableMode: pgx.Deferrable,
        IsoLevel:       pgx.ReadCommitted,
        AccessMode:     pgx.ReadWrite,
    })

    if err != nil {
        return nil, nil, err
    }

    return d.WithTx(txn), txn, nil
}

func (d *Database) FinalizeTx(ctx context.Context, txn pgx.Tx, err error, serviceErr *exceptions.ServiceError) {
    if serviceErr != nil || err != nil {
        if err := txn.Rollback(ctx); err != nil {
            panic(err)
        }
        return
    }
    if commitErr := txn.Commit(ctx); commitErr != nil {
        panic(commitErr)
    }
    if p := recover(); p != nil {
        if err := txn.Rollback(ctx); err != nil {
            panic(err)
        }
        panic(p)
    }
}

func (d *Database) RawQuery(ctx context.Context, sql string, args []interface{}) (pgx.Rows, error) {
    return d.connPool.Query(ctx, sql, args...)
}

func (d *Database) RawQueryRow(ctx context.Context, sql string, args []interface{}) pgx.Row {
    return d.connPool.QueryRow(ctx, sql, args...)
}

func (d *Database) Ping(ctx context.Context) error {
    return d.connPool.Ping(ctx)
}

Encryption

Encryption is less of a provider, but more of a logic isolation, hence we'll still load it as a provider.

Utilities

For each secret (or key), we will need to derive a KID (Key ID), this is common logic, so in the internal directory lets create a utils package and on a jwk.go folder add the following files:

  • encoders.go:

    package utils
    
    import (
        "math/big"
    )
    
    func Base62Encode(bytes []byte) string {
        var codeBig big.Int
        codeBig.SetBytes(bytes)
        return codeBig.Text(62)
    }
    
  • jwk.go:

    package utils
    
    import (
        "crypto/sha256"
    )
    
    func ExtractKeyID(keyBytes []byte) string {
        hash := sha256.Sum256(keyBytes)
        return Base62Encode(hash[:12])
    }
    
  • secrets.go:

package utils

  import (
      "crypto/rand"
      "encoding/base32"
      "encoding/base64"
      "encoding/hex"
      "math/big"
  )

  func generateRandomBytes(byteLen int) ([]byte, error) {
      b := make([]byte, byteLen)

      if _, err := rand.Read(b); err != nil {
          return nil, err
      }

      return b, nil
  }

  func GenerateBase64Secret(byteLen int) (string, error) {
      randomBytes, err := generateRandomBytes(byteLen)
      if err != nil {
          return "", err
      }

      return base64.RawURLEncoding.EncodeToString(randomBytes), nil
  }

  func DecodeBase64Secret(secret string) ([]byte, error) {
      decoded, err := base64.RawURLEncoding.DecodeString(secret)
      if err != nil {
          return nil, err
      }

      return decoded, nil
  }

  func GenerateBase62Secret(byteLen int) (string, error) {
      randomBytes, err := generateRandomBytes(byteLen)
      if err != nil {
          return "", err
      }

      randomInt := new(big.Int).SetBytes(randomBytes)
      return randomInt.Text(62), nil
  }

  func GenerateBase32Secret(byteLen int) (string, error) {
      randomBytes, err := generateRandomBytes(byteLen)
      if err != nil {
          return "", err
      }

      return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes), nil
  }

  func GenerateHexSecret(byteLen int) (string, error) {
      randomBytes, err := generateRandomBytes(byteLen)
      if err != nil {
          return "", err
      }

      return hex.EncodeToString(randomBytes), nil
  }

Provider

Now with the utilites done, create the following providers/encryption package:

package encryption

import (
    "encoding/base64"
    "log/slog"

    "github.com/tugascript/devlogs/idp/internal/config"
    "github.com/tugascript/devlogs/idp/internal/utils"
)

const logLayer string = "encryption"

type Secret struct {
    kid string
    key []byte
}

type Encryption struct {
    logger           *slog.Logger
    accountSecretKey Secret
    appSecretKey     Secret
    userSecretKey    Secret
    oldSecrets       map[string][]byte
    backendDomain    string
}

func decodeSecret(secret string) Secret {
        // Decode the Base64 code to bytes
    decodedKey, err := base64.StdEncoding.DecodeString(secret)
    if err != nil {
        panic(err)
    }

    return Secret{
                // Generate a Key ID per secret
        kid: utils.ExtractKeyID(decodedKey),
        key: decodedKey,
    }
}

func NewEncryption(
    logger *slog.Logger,
    cfg config.EncryptionConfig,
    backendDomain string,
) *Encryption {
    oldSecretsMap := make(map[string][]byte)
    for _, s := range cfg.OldSecrets() {
        ds := decodeSecret(s)
        oldSecretsMap[ds.kid] = ds.key
    }

    return &Encryption{
        logger:           logger,
        accountSecretKey: decodeSecret(cfg.AccountSecret()),
        appSecretKey:     decodeSecret(cfg.AppSecret()),
        userSecretKey:    decodeSecret(cfg.UserSecret()),
        oldSecrets:       oldSecretsMap,
        backendDomain:    backendDomain,
    }
}

DEK Generation

DEKs (Date Encryption Keys) need to be generated by the system, and are saved in the database, for this reason, create a file called dek.go and add the following logic:

  • DEK generation & Encryption:
package encryption

import (
    "fmt"

    "github.com/tugascript/devlogs/idp/internal/utils"
)

const dekLocation string = "dek"

func generateDEK(keyID string, key []byte) ([]byte, string, error) {
    base64DEK, err := utils.GenerateBase64Secret(32)
    if err != nil {
        return nil, "", err
    }

    encryptedDEK, err := utils.Encrypt(base64DEK, key)
    if err != nil {
        return nil, "", err
    }

    dek, err := utils.DecodeBase64Secret(base64DEK)
    if err != nil {
        return nil, "", err
    }

    return dek, fmt.Sprintf("%s:%s", keyID, encryptedDEK), nil
}

// ...

func reEncryptDEK(isOldKey bool, dek, key []byte) (string, error) {
    if !isOldKey {
        return "", nil
    }

    return utils.Encrypt(base64.RawURLEncoding.EncodeToString(dek), key)
}
  • DEK decryption:
package encryption

import (
    "context"
    "encoding/base64"
    "errors"
    "fmt"
    "log/slog"
    "strings"

    "github.com/tugascript/devlogs/idp/internal/utils"
)

// ...

type decryptDEKOptions struct {
    storedDEK  string
    secret     *Secret
    oldSecrets map[string][]byte
}

func decryptDEK(
    logger *slog.Logger,
    ctx context.Context,
    opts decryptDEKOptions,
) ([]byte, bool, error) {
    dekID, encryptedDEK, err := splitDEK(opts.storedDEK)
    if err != nil {
        logger.ErrorContext(ctx, "Failed to split DEK", "error", err)
        return nil, false, err
    }

    key := opts.secret.key
    oldKey := dekID != opts.secret.kid
    if oldKey {
        var ok bool
        key, ok = opts.oldSecrets[dekID]
        if !ok {
            logger.ErrorContext(ctx, "DEK key ID not found")
            return nil, false, errors.New("secret key not found")
        }
    }

    base64DEK, err := utils.Decrypt(encryptedDEK, key)
    if err != nil {
        logger.ErrorContext(ctx, "Failed to decrypt DEK", "error", err)
        return nil, false, err
    }

    dek, err := utils.DecodeBase64Secret(base64DEK)
    if err != nil {
        logger.ErrorContext(ctx, "Failed to decode DEK", "error", err)
        return nil, false, err
    }

    return dek, oldKey, nil
}
  • Methods to decrypt for Account, App & User:
// ...

func (e *Encryption) decryptAccountDEK(ctx context.Context, requestID, storedDEK string) ([]byte, bool, error) {
    logger := utils.BuildLogger(e.logger, utils.LoggerOptions{
        Layer:     logLayer,
        Location:  dekLocation,
        Method:    "decryptAccountDEK",
        RequestID: requestID,
    })
    logger.DebugContext(ctx, "Decrypting Account DEK...")
    return decryptDEK(logger, ctx, decryptDEKOptions{
        storedDEK:  storedDEK,
        secret:     &e.accountSecretKey,
        oldSecrets: e.oldSecrets,
    })
}

func (e *Encryption) decryptAppDEK(ctx context.Context, requestID, storedDEK string) ([]byte, bool, error) {
    logger := utils.BuildLogger(e.logger, utils.LoggerOptions{
        Layer:     logLayer,
        Location:  dekLocation,
        Method:    "decryptAppDEK",
        RequestID: requestID,
    })
    logger.DebugContext(ctx, "Decrypting App DEK...")
    return decryptDEK(logger, ctx, decryptDEKOptions{
        storedDEK:  storedDEK,
        secret:     &e.appSecretKey,
        oldSecrets: e.oldSecrets,
    })
}

func (e *Encryption) decryptUserDEK(ctx context.Context, requestID, storedDEK string) ([]byte, bool, error) {
    logger := utils.BuildLogger(e.logger, utils.LoggerOptions{
        Layer:     logLayer,
        Location:  dekLocation,
        Method:    "decryptUserDEK",
        RequestID: requestID,
    })
    logger.DebugContext(ctx, "Decrypting User DEK...")
    return decryptDEK(logger, ctx, decryptDEKOptions{
        storedDEK:  storedDEK,
        secret:     &e.userSecretKey,
        oldSecrets: e.oldSecrets,
    })
}

Mailer

The mailer will just be a redis publisher, that will publish messages to the email service, hence create a mailer package with the following mailer.go file:

package mailer

import (
    "context"
    "encoding/json"
    "log/slog"

    "github.com/tugascript/devlogs/idp/internal/utils"

    "github.com/redis/go-redis/v9"
)

const logLayer string = "mailer"

type email struct {
    To      string `json:"to"`
    Subject string `json:"subject"`
    Body    string `json:"body"`
}

type EmailPublisher struct {
    client         redis.UniversalClient
    pubChannel     string
    frontendDomain string
    logger         *slog.Logger
}

func NewEmailPublisher(
    client redis.UniversalClient,
    pubChannel,
    frontendDomain string,
    logger *slog.Logger,
) *EmailPublisher {
    return &EmailPublisher{
        client:         client,
        pubChannel:     pubChannel,
        frontendDomain: frontendDomain,
        logger:         logger,
    }
}

type PublishEmailOptions struct {
    To        string
    Subject   string
    Body      string
    RequestID string
}

func (e *EmailPublisher) publishEmail(ctx context.Context, opts PublishEmailOptions) error {
    logger := utils.BuildLogger(e.logger, utils.LoggerOptions{
        Layer:     logLayer,
        Location:  "mailer",
        Method:    "PublishEmail",
        RequestID: opts.RequestID,
    })
    logger.DebugContext(ctx, "Publishing email...")

    message, err := json.Marshal(email{
        To:      opts.To,
        Subject: opts.Subject,
        Body:    opts.Body,
    })
    if err != nil {
        logger.ErrorContext(ctx, "Failed to marshal email", "error", err)
        return err
    }

    return e.client.Publish(ctx, e.pubChannel, string(message)).Err()
}

It will consume the UniversalClient from the cache, and publish to a given channel.

OAuth

For OAuth 2.0 we can just use the golang.org/x/oauth2 package, just run:

$ go get golang.org/x/oauth2

Scopes

Each external provider has their own scopes, lets start by creating a scopes struct:

package oauth

// ...

type oauthScopes struct {
    email    string
    profile  string
    birthday string
    location string
    gender   string
}

And for each provider lets create a global scope variable:

  • apple.go:

    package oauth
    
    // ...
    
    var appleScopes = oauthScopes{
        email:   "email",
        profile: "name",
    }
    
  • facebook.go:

    package oauth
    
    // ...
    
    var facebookScopes = oauthScopes{
        email:    "email",
        profile:  "public_profile",
        birthday: "user_birthday",
        location: "user_location",
        gender:   "gender",
    }
    
  • github.go:

    package oauth
    
    // ...
    
    var gitHubScopes = oauthScopes{
        email:   "user:email",
        profile: "read:user",
    }
    
  • google.go:

    package oauth
    
    // ...
    
    var googleScopes = oauthScopes{
        email:    "https://www.googleapis.com/auth/userinfo.email",
        profile:  "https://www.googleapis.com/auth/userinfo.profile",
        birthday: "https://www.googleapis.com/auth/user.birthday.read",
        location: "https://www.googleapis.com/auth/user.addresses.read",
        gender:   "https://www.googleapis.com/auth/user.gender.read",
    }
    
  • microsoft.go:

    package oauth
    
    // ...
    
    var microsoftScopes = oauthScopes{
        email:   "User.Read",
        profile: "User.ReadBasic.All",
    }
    

Provider

Start by creating an oauth2.Config struct for each external provider, using existing defaults when possible:

package oauth

import (
    "log/slog"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/facebook"
    "golang.org/x/oauth2/github"
    "golang.org/x/oauth2/google"
    "golang.org/x/oauth2/microsoft"

    "github.com/tugascript/devlogs/idp/internal/config"
)

// ...

type Config struct {
    Enabled bool
    oauth2.Config
}

type Providers struct {
    gitHub    Config
    google    Config
    facebook  Config
    apple     Config
    microsoft Config
    logger    *slog.Logger
}

func NewProviders(
    log *slog.Logger,
    githubCfg,
    googleCfg,
    facebookCfg,
    appleCfg,
    microsoftCfg config.OAuthProviderConfig,
) *Providers {
    return &Providers{
        gitHub: Config{
            Config: oauth2.Config{
                ClientID:     githubCfg.ClientID(),
                ClientSecret: githubCfg.ClientSecret(),
                Endpoint:     github.Endpoint,
                Scopes:       []string{gitHubScopes.email},
            },
            Enabled: githubCfg.Enabled(),
        },
        google: Config{
            Config: oauth2.Config{
                ClientID:     googleCfg.ClientID(),
                ClientSecret: googleCfg.ClientSecret(),
                Endpoint:     google.Endpoint,
                Scopes:       []string{googleScopes.email},
            },
            Enabled: googleCfg.Enabled(),
        },
        facebook: Config{
            Config: oauth2.Config{
                ClientID:     facebookCfg.ClientID(),
                ClientSecret: facebookCfg.ClientSecret(),
                Endpoint:     facebook.Endpoint,
                Scopes:       []string{facebookScopes.email},
            },
            Enabled: facebookCfg.Enabled(),
        },
        apple: Config{
            Config: oauth2.Config{
                ClientID:     appleCfg.ClientID(),
                ClientSecret: appleCfg.ClientSecret(),
                Endpoint: oauth2.Endpoint{
                    AuthURL:  "https://appleid.apple.com/auth/authorize",
                    TokenURL: "https://appleid.apple.com/auth/token",
                },
                Scopes: []string{appleScopes.email},
            },
            Enabled: appleCfg.Enabled(),
        },
        microsoft: Config{
            Config: oauth2.Config{
                ClientID:     microsoftCfg.ClientID(),
                ClientSecret: microsoftCfg.ClientSecret(),
                Endpoint:     microsoft.AzureADEndpoint("common"),
                Scopes:       []string{microsoftScopes.email},
            },
            Enabled: microsoftCfg.Enabled(),
        },
        logger: log,
    }
}

For getting the token we will need to pass the correct Scopes, this touches on a concept in go that is passing a value as a value (or copy) of the underlying resource. Since we dynamically add scopes we create a copy of the config each time.

Getting the access token:

package oauth

import (
    "context"
    // ...

    "github.com/tugascript/devlogs/idp/internal/exceptions"
)


// ...

func mapScopes(scopes []Scope, oas oauthScopes) []string {
    scopeMapper := make(map[string]bool)

    for _, s := range scopes {
        switch s {
        case ScopeBirthday:
            scopeMapper[oas.birthday] = true
        case ScopeGender:
            scopeMapper[oas.gender] = true
        case ScopeLocation:
            scopeMapper[oas.location] = true
        case ScopeProfile:
            scopeMapper[oas.location] = true
        }
    }

    mappedScopes := make([]string, 0, len(scopeMapper))
    for k := range scopeMapper {
        if k != "" {
            mappedScopes = append(mappedScopes, k)
        }
    }

    return mappedScopes
}

// Here we pass by value so we don't update the base configuration
func appendScopes(cfg Config, scopes []string) Config {
    cfg.Scopes = append(cfg.Scopes, scopes...)
    return cfg
}

// Here we pass by value so we don't update the base configuration
func getConfig(cfg Config, redirectURL string, oas oauthScopes, scopes []Scope) Config {
    cfg.RedirectURL = redirectURL

    if scopes != nil {
        return appendScopes(cfg, mapScopes(scopes, oas))
    }

    return cfg
}

type getAccessTokenOptions struct {
    logger      *slog.Logger
    cfg         Config
    redirectURL string
    oas         oauthScopes
    scopes      []Scope
    code        string
}

func getAccessToken(ctx context.Context, opts getAccessTokenOptions) (string, *exceptions.ServiceError) {
    opts.logger.DebugContext(ctx, "Getting access token...")

    if !opts.cfg.Enabled {
        opts.logger.DebugContext(ctx, "OAuth config is disabled")
        return "", exceptions.NewNotFoundError()
    }

    cfg := getConfig(opts.cfg, opts.redirectURL, opts.oas, opts.scopes)
    token, err := cfg.Exchange(ctx, opts.code)
    if err != nil {
        opts.logger.ErrorContext(ctx, "Failed to exchange the code for a token", "error", err)
        return "", exceptions.NewUnauthorizedError()
    }

    opts.logger.DebugContext(ctx, "Access token exchanged successfully")
    return token.AccessToken, nil
}

Getting the authorization URL:

package oauth

import (
    "context"

    // ...
    "github.com/tugascript/devlogs/idp/internal/utils"
)

type getAuthorizationURLOptions struct {
    logger      *slog.Logger
    redirectURL string
    cfg         Config
    oas         oauthScopes
    scopes      []Scope
}

func getAuthorizationURL(
    ctx context.Context,
    opts getAuthorizationURLOptions,
) (string, string, *exceptions.ServiceError) {
    opts.logger.DebugContext(ctx, "Getting authorization url...")

    if !opts.cfg.Enabled {
        opts.logger.DebugContext(ctx, "OAuth config is disabled")
        return "", "", exceptions.NewNotFoundError()
    }

    state, err := utils.GenerateHexSecret(16)
    if err != nil {
        opts.logger.ErrorContext(ctx, "Failed to generate state", "error", err)
        return "", "", exceptions.NewServerError()
    }

    cfg := getConfig(opts.cfg, opts.redirectURL, opts.oas, opts.scopes)
    url := cfg.AuthCodeURL(state)
    opts.logger.DebugContext(ctx, "Authorization url generated successfully")
    return url, state, nil
}

Getting the user data:

package oauth

import (
    "context"
    "errors"
    "io"
    "log/slog"
    "net/http"

    // ...

    "github.com/tugascript/devlogs/idp/internal/config"
    "github.com/tugascript/devlogs/idp/internal/exceptions"
    "github.com/tugascript/devlogs/idp/internal/utils"
)

func getUserResponse(logger *slog.Logger, ctx context.Context, url, token string) ([]byte, int, error) {
    logger.DebugContext(ctx, "Getting user data...", "url", url)

    logger.DebugContext(ctx, "Building user data request")
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        logger.ErrorContext(ctx, "Failed to build user data request")
        return nil, 0, err
    }

    req.Header.Set("Accept", "application/json")
    req.Header.Set("Authorization", "Bearer "+token)

    logger.DebugContext(ctx, "Requesting user data...")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        logger.ErrorContext(ctx, "Failed to request the user data")
        return nil, 0, err
    }

    if res.StatusCode != http.StatusOK {
        logger.ErrorContext(ctx, "Responded with a non 200 OK status", "status", res.StatusCode)
        return nil, res.StatusCode, errors.New("status code is not 200 OK")
    }

    logger.DebugContext(ctx, "Reading the body")
    body, err := io.ReadAll(res.Body)
    if err != nil {
        logger.ErrorContext(ctx, "Failed to read the body", "error", err)
        return nil, 0, err
    }
    defer func() {
        if err := res.Body.Close(); err != nil {
            logger.ErrorContext(ctx, "Failed to close response body", "error", err)
        }
    }()

    return body, res.StatusCode, nil
}

type UserLocation struct {
    City    string
    Region  string
    Country string
}

type UserData struct {
    Name       string
    FirstName  string
    LastName   string
    Username   string
    Picture    string
    Email      string
    Gender     string
    Location   UserLocation
    BirthDate  string
    IsVerified bool
}

type ToUserData interface {
    ToUserData() UserData
}

type extraParams struct {
    params string
}

func (p *extraParams) addParam(prm string) {
    if p.params != "" {
        p.params = p.params + "," + prm
        return
    }

    p.params += prm
}

func (p *extraParams) isEmpty() bool {
    return p.params == ""
}

Common options between all providers:

package oauth

// ...

type AccessTokenOptions struct {
    RequestID   string
    Code        string
    RedirectURL string
    Scopes      []Scope
}

type AuthorizationURLOptions struct {
    RequestID   string
    RedirectURL string
    Scopes      []Scope
}

type UserDataOptions struct {
    RequestID string
    Token     string
    Scopes    []Scope
}

Tokens

Keys Algorithms

When choosing the algorithm of the key pairs to sign and verify JWTs we need to choose the best ones with the best efficient and security ratio in mind.

By my own research the most balanced algorithm would be EdDSC (Edwards-curve Digital Signature Algorithm) with the Ed25519 signature scheme, however this algorithm is not part of the base JWT RFC-7519 standard, only the RFC-8037 which is not widly supported.

Hence for simplicity and compatibility sake for all tokens that require their public keys to be distributed will use the recommended ECDSA (Elliptic Curve Digital Signature Algorithm) algorithm with the P-256 signature scheme.

Utilities

For sharing and saving keys we need to convert them to JWKs so create an encode and decode functions for both Ed25519 and P256 on the utils/jwk.go file:

package utils

import (
    "crypto/ecdsa"
    "crypto/ed25519"
    "crypto/elliptic"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
    "math/big"
)

type Ed25519JWK struct {
    Kty    string   `json:"kty"`     // Key Type (OKP for Ed25519)
    Crv    string   `json:"crv"`     // Curve (Ed25519)
    X      string   `json:"x"`       // Public Key
    Use    string   `json:"use"`     // Usage (e.g., "sig" for signing)
    Alg    string   `json:"alg"`     // Algorithm (EdDSA for Ed25519)
    Kid    string   `json:"kid"`     // Key AccountID
    KeyOps []string `json:"key_ops"` // Key Operations
}

type P256JWK struct {
    Kty    string   `json:"kty"`     // Key Type (EC for Elliptic Curve)
    Crv    string   `json:"crv"`     // Curve (P-256)
    X      string   `json:"x"`       // X Coordinate
    Y      string   `json:"y"`       // Y Coordinate
    Use    string   `json:"use"`     // Usage (e.g., "sig" for signing)
    Alg    string   `json:"alg"`     // Algorithm (ES256 for P-256)
    Kid    string   `json:"kid"`     // Key AccountID
    KeyOps []string `json:"key_ops"` // Key Operations
}

// Because of Apple
type RS256JWK struct {
    Kty    string   `json:"kty"`
    Kid    string   `json:"kid"`
    Use    string   `json:"use"`
    Alg    string   `json:"alg"`
    N      string   `json:"n"`
    E      string   `json:"e"`
    KeyOps []string `json:"key_ops,omitempty"`
}

const (
    kty    string = "OKP"
    crv    string = "Ed25519"
    use    string = "sig"
    alg    string = "EdDSA"
    verify string = "verify"

    p256Kty string = "EC"
    p256Crv string = "P-256"
)

// ...

func EncodeEd25519Jwk(publicKey ed25519.PublicKey, kid string) Ed25519JWK {
    return Ed25519JWK{
        Kty:    kty,
        Crv:    crv,
        X:      base64.RawURLEncoding.EncodeToString(publicKey),
        Use:    use,
        Alg:    alg,
        Kid:    kid,
        KeyOps: []string{verify},
    }
}

func DecodeEd25519Jwk(jwk Ed25519JWK) (ed25519.PublicKey, error) {
    publicKey, err := base64.RawURLEncoding.DecodeString(jwk.X)
    if err != nil {
        return nil, err
    }

    return publicKey, nil
}

func EncodeP256Jwk(publicKey *ecdsa.PublicKey, kid string) P256JWK {
    return P256JWK{
        Kty:    p256Kty,
        Crv:    p256Crv,
        X:      base64.RawURLEncoding.EncodeToString(publicKey.X.Bytes()),
        Y:      base64.RawURLEncoding.EncodeToString(publicKey.Y.Bytes()),
        Use:    use,
        Alg:    alg,
        Kid:    kid,
        KeyOps: []string{verify},
    }
}

func DecodeP256Jwk(jwk P256JWK) (ecdsa.PublicKey, error) {
    x, err := base64.RawURLEncoding.DecodeString(jwk.X)
    if err != nil {
        return ecdsa.PublicKey{}, err
    }

    y, err := base64.RawURLEncoding.DecodeString(jwk.Y)
    if err != nil {
        return ecdsa.PublicKey{}, err
    }

    return ecdsa.PublicKey{
        Curve: elliptic.P256(),
        X:     new(big.Int).SetBytes(x),
        Y:     new(big.Int).SetBytes(y),
    }, nil
}

func DecodeRS256Jwk(jwk RS256JWK) (*rsa.PublicKey, error) {
    nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
    if err != nil {
        return nil, err
    }
    n := new(big.Int).SetBytes(nBytes)

    eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
    if err != nil {
        return nil, err
    }
    e := big.NewInt(0).SetBytes(eBytes).Int64()

    if e <= 0 {
        return nil, fmt.Errorf("invalid RSA exponent")
    }

    return &rsa.PublicKey{N: n, E: int(e)}, nil
}

Provider

For each key we need a key pair, and a reference to a previous public key for keys rotation, create the tokens/tokens.go package:

package tokens

import (
    "crypto/ecdsa"
    "crypto/ed25519"
    "crypto/x509"
    "encoding/pem"

    "github.com/golang-jwt/jwt/v5"

    "github.com/tugascript/devlogs/idp/internal/config"
    "github.com/tugascript/devlogs/idp/internal/utils"
)

type PreviousPublicKey struct {
    publicKey ed25519.PublicKey
    kid       string
}

type TokenKeyPair struct {
    publicKey  ed25519.PublicKey
    privateKey ed25519.PrivateKey
    kid        string
}

type TokenSecretData struct {
    curKeyPair TokenKeyPair
    prevPubKey *PreviousPublicKey
    ttlSec     int64
}

// ...

type PreviousEs256PublicKey struct {
    publicKey *ecdsa.PublicKey
    kid       string
}

type Es256TokenKeyPair struct {
    privateKey *ecdsa.PrivateKey
    publicKey  *ecdsa.PublicKey
    kid        string
}

type Es256TokenSecretData struct {
    curKeyPair Es256TokenKeyPair
    prevPubKey *PreviousEs256PublicKey
    ttlSec     int64
}

Now each key is encoded as a PEM in the environment, hence we need to decode the x509 certificates for:

  • Ed25519 keys:
package tokens

// ...

func extractEd25519PublicKey(publicKey string) (ed25519.PublicKey, string) {
    publicKeyBlock, _ := pem.Decode([]byte(publicKey))
    if publicKeyBlock == nil || publicKeyBlock.Type != "PUBLIC KEY" {
        panic("Invalid public key")
    }

    publicKeyData, err := x509.ParsePKIXPublicKey(publicKeyBlock.Bytes)
    if err != nil {
        panic(err)
    }

    publicKeyValue, ok := publicKeyData.(ed25519.PublicKey)
    if !ok {
        panic("Invalid public key")
    }

    return publicKeyValue, utils.ExtractKeyID(publicKeyValue)
}

func extractEd25519PrivateKey(privateKey string) ed25519.PrivateKey {
    privateKeyBlock, _ := pem.Decode([]byte(privateKey))
    if privateKeyBlock == nil || privateKeyBlock.Type != "PRIVATE KEY" {
        panic("Invalid private key")
    }

    privateKeyData, err := x509.ParsePKCS8PrivateKey(privateKeyBlock.Bytes)
    if err != nil {
        panic(err)
    }

    privateKeyValue, ok := privateKeyData.(ed25519.PrivateKey)
    if !ok {
        panic("Invalid private key")
    }

    return privateKeyValue
}

func extractEd25519PublicPrivateKeyPair(publicKey, privateKey string) TokenKeyPair {
    pubKey, kid := extractEd25519PublicKey(publicKey)
    return TokenKeyPair{
        publicKey:  pubKey,
        privateKey: extractEd25519PrivateKey(privateKey),
        kid:        kid,
    }
}

func newTokenSecretData(
    publicKey,
    privateKey,
    previousPublicKey string,
    ttlSec int64,
) TokenSecretData {
    curKeyPair := extractEd25519PublicPrivateKeyPair(publicKey, privateKey)

    if previousPublicKey != "" {
        pubKey, kid := extractEd25519PublicKey(previousPublicKey)
        return TokenSecretData{
            curKeyPair: curKeyPair,
            prevPubKey: &PreviousPublicKey{publicKey: pubKey, kid: kid},
            ttlSec:     ttlSec,
        }
    }

    return TokenSecretData{
        curKeyPair: curKeyPair,
        ttlSec:     ttlSec,
    }
}
  • Es256 keys:
package tokens

// ...

func extractEs256KeyPair(privateKey string) Es256TokenKeyPair {
    privateKeyBlock, _ := pem.Decode([]byte(privateKey))
    if privateKeyBlock == nil || privateKeyBlock.Type != "PRIVATE KEY" {
        panic("Invalid private key")
    }

    privateKeyData, err := x509.ParsePKCS8PrivateKey(privateKeyBlock.Bytes)
    if err != nil {
        privateKeyData, err = x509.ParseECPrivateKey(privateKeyBlock.Bytes)
        if err != nil {
            panic(err)
        }
    }

    privateKeyValue, ok := privateKeyData.(*ecdsa.PrivateKey)
    if !ok {
        panic("Invalid private key")
    }

    publicKeyValue, err := x509.MarshalPKIXPublicKey(&privateKeyValue.PublicKey)
    if err != nil {
        panic(err)
    }

    return Es256TokenKeyPair{
        privateKey: privateKeyValue,
        publicKey:  &privateKeyValue.PublicKey,
        kid:        utils.ExtractKeyID(publicKeyValue),
    }
}

func extractEs256PublicKey(publicKey string) (*ecdsa.PublicKey, string) {
    publicKeyBlock, _ := pem.Decode([]byte(publicKey))
    if publicKeyBlock == nil || publicKeyBlock.Type != "PUBLIC KEY" {
        panic("Invalid public key")
    }

    publicKeyData, err := x509.ParsePKIXPublicKey(publicKeyBlock.Bytes)
    if err != nil {
        panic(err)
    }

    pubKey, ok := publicKeyData.(*ecdsa.PublicKey)
    if !ok {
        panic("Invalid public key")
    }

    publicKeyValue, err := x509.MarshalPKIXPublicKey(pubKey)
    if err != nil {
        panic(err)
    }

    return pubKey, utils.ExtractKeyID(publicKeyValue)
}

// ...

func newEs256TokenSecretData(privateKey, previousPublicKey string, ttlSec int64) Es256TokenSecretData {
    curKeyPair := extractEs256KeyPair(privateKey)

    if previousPublicKey != "" {
        prevPubKey, kid := extractEs256PublicKey(previousPublicKey)
        return Es256TokenSecretData{
            curKeyPair: curKeyPair,
            prevPubKey: &PreviousEs256PublicKey{publicKey: prevPubKey, kid: kid},
            ttlSec:     ttlSec,
        }
    }

    return Es256TokenSecretData{
        curKeyPair: curKeyPair,
        ttlSec:     ttlSec,
    }
}

Finally finish by creating the full provider:

package tokens

// ...

type Tokens struct {
    frontendDomain         string
    backendDomain          string
    accessData             Es256TokenSecretData
    accountCredentialsData Es256TokenSecretData
    refreshData            TokenSecretData
    confirmationData       TokenSecretData
    resetData              TokenSecretData
    oauthData              TokenSecretData
    twoFAData              TokenSecretData
    jwks                   []utils.P256JWK
}

func NewTokens(
    accessCfg,
    accountCredentialsCfg,
    refreshCfg,
    confirmationCfg,
    resetCfg,
    oauthCfg,
    twoFACfg config.SingleJwtConfig,
    frontendDomain,
    backendDomain string,
) *Tokens {
    accessData := newEs256TokenSecretData(
        accessCfg.PrivateKey(),
        accessCfg.PreviousPublicKey(),
        accessCfg.TtlSec(),
    )
    accountKeysData := newEs256TokenSecretData(
        accountCredentialsCfg.PrivateKey(),
        accountCredentialsCfg.PreviousPublicKey(),
        accountCredentialsCfg.TtlSec(),
    )

    jwks := []utils.P256JWK{
        utils.EncodeP256Jwk(accountKeysData.curKeyPair.publicKey, accountKeysData.curKeyPair.kid),
        utils.EncodeP256Jwk(accessData.curKeyPair.publicKey, accessData.curKeyPair.kid),
    }

    if accountKeysData.prevPubKey != nil {
        jwks = append(jwks, utils.EncodeP256Jwk(
            accountKeysData.prevPubKey.publicKey,
            accessData.prevPubKey.kid,
        ))
    }
    if accessData.prevPubKey != nil {
        jwks = append(jwks, utils.EncodeP256Jwk(
            accessData.prevPubKey.publicKey,
            accessData.prevPubKey.kid,
        ))
    }

    return &Tokens{
        accessData:             accessData,
        accountCredentialsData: accountKeysData,
        refreshData: newTokenSecretData(
            refreshCfg.PublicKey(),
            refreshCfg.PrivateKey(),
            refreshCfg.PreviousPublicKey(),
            refreshCfg.TtlSec(),
        ),
        confirmationData: newTokenSecretData(
            confirmationCfg.PublicKey(),
            confirmationCfg.PrivateKey(),
            confirmationCfg.PreviousPublicKey(),
            confirmationCfg.TtlSec(),
        ),
        resetData: newTokenSecretData(
            resetCfg.PublicKey(),
            resetCfg.PrivateKey(),
            resetCfg.PreviousPublicKey(),
            resetCfg.TtlSec(),
        ),
        oauthData: newTokenSecretData(
            oauthCfg.PublicKey(),
            oauthCfg.PrivateKey(),
            oauthCfg.PreviousPublicKey(),
            oauthCfg.TtlSec(),
        ),
        twoFAData: newTokenSecretData(
            twoFACfg.PublicKey(),
            twoFACfg.PrivateKey(),
            twoFACfg.PreviousPublicKey(),
            twoFACfg.TtlSec(),
        ),
        frontendDomain: frontendDomain,
        backendDomain:  backendDomain,
        jwks:           jwks,
    }
}

func (t *Tokens) JWKs() []utils.P256JWK {
    return t.jwks
}

Error Handling

Error handling in Go works differently from most languages, instead of throwing and catching exceptions, values are return as a pointer (error as values) to the error.

This means for ease of use, we need to map/coerce the errors to a known value on the service layer, and map them to an error response on the controller layer.

Service Errors

Create an expections directory on the internal folder for the errors mapping.

Format

The service error is gonna have a standard error class, with a type/code, and a message, but for simplicity we won't add a details slice field.

Create the exceptions/services.go file for the exceptions package:

package exceptions

type ServiceError struct {
    Code    string
    Message string
}

func NewError(code string, message string) *ServiceError {
    return &ServiceError{
        Code:    code,
        Message: message,
    }
}

// To make `ServiceError` an error type interface
func (e *ServiceError) Error() string {
    return e.Message
}

Codes and messages

We need to standerdize the code and message based on HTTP status codes:

package exceptions

// ...

const (
    CodeValidation           string = "VALIDATION"
    CodeConflict             string = "CONFLICT"
    CodeInvalidEnum          string = "INVALID_ENUM"
    CodeNotFound             string = "NOT_FOUND"
    CodeUnknown              string = "UNKNOWN"
    CodeServerError          string = "SERVER_ERROR"
    CodeUnauthorized         string = "UNAUTHORIZED"
    CodeForbidden            string = "FORBIDDEN"
    CodeUnsupportedMediaType string = "UNSUPPORTED_MEDIA_TYPE"
)

const (
    MessageDuplicateKey string = "Resource already exists"
    MessageNotFound     string = "Resource not found"
    MessageUnknown      string = "Something went wrong"
    MessageUnauthorized string = "Unauthorized"
    MessageForbidden    string = "Forbidden"
)

func NewNotFoundError() *ServiceError {
    return NewError(CodeNotFound, MessageNotFound)
}

func NewValidationError(message string) *ServiceError {
    return NewError(CodeValidation, message)
}

func NewServerError() *ServiceError {
    return NewError(CodeServerError, MessageUnknown)
}

func NewConflictError(message string) *ServiceError {
    return NewError(CodeConflict, message)
}

func NewUnsupportedMediaTypeError(message string) *ServiceError {
    return NewError(CodeUnsupportedMediaType, message)
}

func NewUnauthorizedError() *ServiceError {
    return NewError(CodeUnauthorized, MessageUnauthorized)
}

func NewForbiddenError() *ServiceError {
    return NewError(CodeForbidden, MessageForbidden)
}

Coercion

For the PostgreSQL errors we can coerce them using a mapper:

package exceptions

import (
    "errors"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
)

// ...

func FromDBError(err error) *ServiceError {
    if errors.Is(err, pgx.ErrNoRows) {
        return NewError(CodeNotFound, MessageNotFound)
    }

    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        switch pgErr.Code {
        case "23505":
            return NewError(CodeConflict, MessageDuplicateKey)
        case "23514":
            return NewError(CodeInvalidEnum, pgErr.Message)
        case "23503":
            return NewError(CodeNotFound, MessageNotFound)
        default:
            return NewError(CodeUnknown, pgErr.Message)
        }
    }

    return NewError(CodeUnknown, MessageUnknown)
}

Controllers Error

ServiceError need to be mappend to a JSON Response, however there are also other types of error responses: body validation and oauth validation.

Error Response

Create the controllers.go and add the error response, which is the same as ServiceError but JSON parsable:

package exceptions 

// ...

const (
    StatusConflict     string = "Conflict"
    StatusInvalidEnum  string = "BadRequest"
    StatusNotFound     string = "NotFound"
    StatusServerError  string = "InternalServerError"
    StatusUnknown      string = "InternalServerError"
    StatusUnauthorized string = "Unauthorized"
    StatusForbidden    string = "Forbidden"
    StatusValidation   string = "Validation"
)

type ErrorResponse struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

func NewErrorResponse(err *ServiceError) ErrorResponse {
    switch err.Code {
    case CodeServerError:
        return ErrorResponse{
            Code:    StatusServerError,
            Message: err.Message,
        }
    case CodeConflict:
        return ErrorResponse{
            Code:    StatusConflict,
            Message: err.Message,
        }
    case CodeInvalidEnum:
        return ErrorResponse{
            Code:    StatusInvalidEnum,
            Message: err.Message,
        }
    case CodeNotFound:
        return ErrorResponse{
            Code:    StatusNotFound,
            Message: err.Message,
        }
    case CodeValidation:
        return ErrorResponse{
            Code:    StatusValidation,
            Message: err.Message,
        }
    case CodeUnknown:
        return ErrorResponse{
            Code:    StatusUnknown,
            Message: err.Message,
        }
    case CodeUnauthorized:
        return ErrorResponse{
            Code:    StatusUnauthorized,
            Message: StatusUnauthorized,
        }
    case CodeForbidden:
        return ErrorResponse{
            Code:    StatusForbidden,
            Message: StatusForbidden,
        }
    default:
        return ErrorResponse{
            Code:    StatusUnknown,
            Message: err.Message,
        }
    }
}

Validation Response

To validation the body inputs we will use the go validator package, start by installing it:

$ go get github.com/go-playground/validator/v10

Services

Services is where most of the server business logic is located, lets create the services/services.go package, the Services struct needs to encapsulate all providers, and be the only layer talking directly to them.

package services

import (
    "log/slog"

    "github.com/tugascript/devlogs/idp/internal/providers/cache"
    "github.com/tugascript/devlogs/idp/internal/providers/database"
    "github.com/tugascript/devlogs/idp/internal/providers/encryption"
    "github.com/tugascript/devlogs/idp/internal/providers/mailer"
    "github.com/tugascript/devlogs/idp/internal/providers/oauth"
    "github.com/tugascript/devlogs/idp/internal/providers/tokens"
)

type Services struct {
    logger         *slog.Logger
    database       *database.Database
    cache          *cache.Cache
    mail           *mailer.EmailPublisher
    jwt            *tokens.Tokens
    encrypt        *encryption.Encryption
    oauthProviders *oauth.Providers
}

func NewServices(
    logger *slog.Logger,
    database *database.Database,
    cache *cache.Cache,
    mail *mailer.EmailPublisher,
    jwt *tokens.Tokens,
    encrypt *encryption.Encryption,
    oauthProv *oauth.Providers,
) *Services {
    return &Services{
        logger:         logger,
        database:       database,
        cache:          cache,
        mail:           mail,
        jwt:            jwt,
        encrypt:        encrypt,
        oauthProviders: oauthProv,
    }
}

We also need a helper to build the logger, and some common constant, create a helpers.go file:

import (
    "log/slog"

    "github.com/tugascript/devlogs/idp/internal/utils"
)

const (
    AuthProviderEmail     string = "email"
    AuthProviderGoogle    string = "google"
    AuthProviderGitHub    string = "github"
    AuthProviderApple     string = "apple"
    AuthProviderMicrosoft string = "microsoft"
    AuthProviderFacebook  string = "facebook"

    TwoFactorNone  string = "none"
    TwoFactorEmail string = "email"
    TwoFactorTotp  string = "totp"
)

func (s *Services) buildLogger(requestID, location, function string) *slog.Logger {
    return utils.BuildLogger(s.logger, utils.LoggerOptions{
        Layer:     utils.ServicesLogLayer,
        Location:  location,
        Method:    function,
        RequestID: requestID,
    })
}

Health

For our first service, we will create the health endpoint for our API, this service only needs to ping PostgreSQL and ValKey.

Create the health.go file:

package services

import (
    "context"

    "github.com/tugascript/devlogs/idp/internal/exceptions"
)

const healthLocation string = "health"

func (s *Services) HealthCheck(ctx context.Context, requestID string) *exceptions.ServiceError {
    logger := s.buildLogger(requestID, healthLocation, "HealthCheck")
    logger.InfoContext(ctx, "Performing health check...")

    if err := s.database.Ping(ctx); err != nil {
        logger.ErrorContext(ctx, "Failed to ping database", "error", err)
        return exceptions.NewServerError()
    }
    if err := s.cache.Ping(ctx); err != nil {
        logger.ErrorContext(ctx, "Failed to ping cache", "error", err)
        return exceptions.NewServerError()
    }

    logger.InfoContext(ctx, "Service is healthy")
    return nil
}

Controllers

Controllers are where we map our services to the correct HTTP status response.

Helpers

For the controllor, error handling and logging is the same for all routes, hence create a logger builder and error handling functions on a helpers.go file.

package controllers

import (
    "errors"
    "fmt"
    "log/slog"

    "github.com/go-playground/validator/v10"
    "github.com/gofiber/fiber/v2"
    "github.com/google/uuid"

    "github.com/tugascript/devlogs/idp/internal/exceptions"
    "github.com/tugascript/devlogs/idp/internal/utils"
)

func (c *Controllers) buildLogger(
    requestID,
    location,
    method string,
) *slog.Logger {
    return utils.BuildLogger(c.logger, utils.LoggerOptions{
        Layer:     utils.ControllersLogLayer,
        Location:  location,
        Method:    method,
        RequestID: requestID,
    })
}

func logRequest(logger *slog.Logger, ctx *fiber.Ctx) {
    logger.InfoContext(
        ctx.UserContext(),
        fmt.Sprintf("Request: %s %s", ctx.Method(), ctx.Path()),
    )
}

func getRequestID(ctx *fiber.Ctx) string {
    return ctx.Get("requestid", uuid.NewString())
}

func logResponse(logger *slog.Logger, ctx *fiber.Ctx, status int) {
    logger.InfoContext(
        ctx.UserContext(),
        fmt.Sprintf("Response: %s %s", ctx.Method(), ctx.Path()),
        "status", status,
    )
}

func validateErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, location string, err error) error {
    logger.WarnContext(ctx.UserContext(), "Failed to validate request", "error", err)
    logResponse(logger, ctx, fiber.StatusBadRequest)

    var errs validator.ValidationErrors
    ok := errors.As(err, &errs)
    if !ok {
        return ctx.
            Status(fiber.StatusBadRequest).
            JSON(exceptions.NewEmptyValidationErrorResponse(location))
    }

    return ctx.
        Status(fiber.StatusBadRequest).
        JSON(exceptions.ValidationErrorResponseFromErr(&errs, location))
}

func validateBodyErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error {
    return validateErrorResponse(logger, ctx, exceptions.ValidationResponseLocationBody, err)
}

func validateURLParamsErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error {
    return validateErrorResponse(logger, ctx, exceptions.ValidationResponseLocationParams, err)
}

func validateQueryParamsErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error {
    return validateErrorResponse(logger, ctx, exceptions.ValidationResponseLocationQuery, err)
}

func serviceErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, serviceErr *exceptions.ServiceError) error {
    status := exceptions.NewRequestErrorStatus(serviceErr.Code)
    resErr := exceptions.NewErrorResponse(serviceErr)
    logResponse(logger, ctx, status)
    return ctx.Status(status).JSON(&resErr)
}

func oauthErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, message string) error {
    resErr := exceptions.NewOAuthError(message)
    logResponse(logger, ctx, fiber.StatusBadRequest)
    return ctx.Status(fiber.StatusBadRequest).JSON(&resErr)
}

func parseRequestErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error {
    logger.WarnContext(ctx.UserContext(), "Failed to parse request", "error", err)
    logResponse(logger, ctx, fiber.StatusBadRequest)
    return ctx.
        Status(fiber.StatusBadRequest).
        JSON(exceptions.NewEmptyValidationErrorResponse(exceptions.ValidationResponseLocationBody))
}

Health

The health controller is pretty simple, since it will only consume the service HealtCheck method directly:

package controllers

import "github.com/gofiber/fiber/v2"

func (c *Controllers) HealthCheck(ctx *fiber.Ctx) error {
    requestID := getRequestID(ctx)
    logger := c.buildLogger(requestID, "health", "HealthCheck")
    logRequest(logger, ctx)

    if serviceErr := c.services.HealthCheck(ctx.UserContext(), requestID); serviceErr != nil {
        return serviceErrorResponse(logger, ctx, serviceErr)
    }

    return ctx.SendStatus(fiber.StatusOK)
}

Paths

On the controllers folder create a paths package with the health.go path:

package paths

const Health string = "/health"

Server

The server is where we will initialize the Fiber App, build Services, Controllers and load common middleware.

Routes

Start by creating a server/routes and add routes.go file with the Routes struct:

package routes

import (
    "github.com/tugascript/devlogs/idp/internal/controllers"
)

type Routes struct {
    controllers *controllers.Controllers
}

func NewRoutes(ctrls *controllers.Controllers) *Routes {
    return &Routes{controllers: ctrls}
}

Now create a router method for the health endpoint on health.go:

package routes

import (
    "github.com/gofiber/fiber/v2"

    "github.com/tugascript/devlogs/idp/internal/controllers/paths"
)

func (r *Routes) HealthRoutes(app *fiber.App) {
    app.Get(paths.Health, r.controllers.HealthCheck)
}

Plus also create a common.go with the route Group for API version 1:

package routes

import "github.com/gofiber/fiber/v2"

const V1Path string = "/v1"

func v1PathRouter(app *fiber.App) fiber.Router {
    return app.Group(V1Path)
}

Server Instance

Server instance is where we hook up most of our logic, hence it will be a big method where you take the config as a parameter and initialize everything.

On the server.go file inside the server directory, and create the FiberServer instance:

package server

import (
    // ...

    // ...
    "github.com/tugascript/devlogs/idp/internal/server/routes"
    // ...
)

type FiberServer struct {
    *fiber.App
    routes *routes.Routes
}

And load everything in the New function that takes the context.Context, the struter logger (*slog.Logger) and the configuration (config.Config):

package server

import (
    "context"
    "log/slog"
    "time"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/gofiber/fiber/v2/middleware/encryptcookie"
    "github.com/gofiber/fiber/v2/middleware/helmet"
    "github.com/gofiber/fiber/v2/middleware/limiter"
    "github.com/gofiber/fiber/v2/middleware/requestid"
    fiberRedis "github.com/gofiber/storage/redis/v3"
    "github.com/google/uuid"
    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/tugascript/devlogs/idp/internal/config"
    "github.com/tugascript/devlogs/idp/internal/controllers"
    "github.com/tugascript/devlogs/idp/internal/providers/cache"
    "github.com/tugascript/devlogs/idp/internal/providers/database"
    "github.com/tugascript/devlogs/idp/internal/providers/encryption"
    "github.com/tugascript/devlogs/idp/internal/providers/mailer"
    "github.com/tugascript/devlogs/idp/internal/providers/oauth"
    "github.com/tugascript/devlogs/idp/internal/providers/tokens"
    "github.com/tugascript/devlogs/idp/internal/server/routes"
    "github.com/tugascript/devlogs/idp/internal/server/validations"
    "github.com/tugascript/devlogs/idp/internal/services"
)

// ...

func New(
    ctx context.Context,
    logger *slog.Logger,
    cfg config.Config,
) *FiberServer {
    logger.InfoContext(ctx, "Building redis storage...")
    cacheStorage := fiberRedis.New(fiberRedis.Config{
        URL: cfg.RedisURL(),
    })
    cc := cache.NewCache(
        logger,
        cacheStorage,
    )
    logger.InfoContext(ctx, "Finished building redis storage")

    logger.InfoContext(ctx, "Building database connection pool...")
    dbConnPool, err := pgxpool.New(ctx, cfg.DatabaseURL())
    if err != nil {
        logger.ErrorContext(ctx, "Failed to connect to database", "error", err)
        panic(err)
    }
    db := database.NewDatabase(dbConnPool)
    logger.InfoContext(ctx, "Finished building database connection pool")

    logger.InfoContext(ctx, "Building mailer...")
    mail := mailer.NewEmailPublisher(
        cc.Client(),
        cfg.EmailPubChannel(),
        cfg.FrontendDomain(),
        logger,
    )
    logger.InfoContext(ctx, "Finished building mailer")

    logger.InfoContext(ctx, "Building JWT token keys...")
    tokensCfg := cfg.TokensConfig()
    jwts := tokens.NewTokens(
        tokensCfg.Access(),
        tokensCfg.AccountCredentials(),
        tokensCfg.Refresh(),
        tokensCfg.Confirm(),
        tokensCfg.Reset(),
        tokensCfg.OAuth(),
        tokensCfg.TwoFA(),
        cfg.FrontendDomain(),
        cfg.BackendDomain(),
    )
    logger.InfoContext(ctx, "Finished building JWT tokens keys")

    logger.InfoContext(ctx, "Building encryption...")
    encryp := encryption.NewEncryption(logger, cfg.EncryptionConfig(), cfg.BackendDomain())
    logger.InfoContext(ctx, "Finished encryption")

    logger.InfoContext(ctx, "Building OAuth provider...")
    oauthProvidersCfg := cfg.OAuthProvidersConfig()
    oauthProviders := oauth.NewProviders(
        logger,
        oauthProvidersCfg.GitHub(),
        oauthProvidersCfg.Google(),
        oauthProvidersCfg.Facebook(),
        oauthProvidersCfg.Apple(),
        oauthProvidersCfg.Microsoft(),
    )
    logger.InfoContext(ctx, "Finished building OAuth provider")

    logger.InfoContext(ctx, "Building services...")
    newServices := services.NewServices(
        logger,
        db,
        cc,
        mail,
        jwts,
        encryp,
        oauthProviders,
    )
    logger.InfoContext(ctx, "Finished building services")

    logger.InfoContext(ctx, "Loading validators...")
    vld := validations.NewValidator(logger)
    logger.InfoContext(ctx, "Finished loading validators")

    server := &FiberServer{
        App: fiber.New(fiber.Config{
            ServerHeader: "idp",
            AppName:      "idp",
        }),
        routes: routes.NewRoutes(controllers.NewControllers(
            logger,
            newServices,
            vld,
            cfg.FrontendDomain(),
            cfg.BackendDomain(),
            cfg.CookieName(),
        )),
    }

    logger.InfoContext(ctx, "Loading middleware...")
    server.Use(helmet.New())
    server.Use(requestid.New(requestid.Config{
        Header: fiber.HeaderXRequestID,
        Generator: func() string {
            return uuid.NewString()
        },
    }))
    rateLimitCfg := cfg.RateLimiterConfig()
    server.Use(limiter.New(limiter.Config{
        Max:               int(rateLimitCfg.Max()),
        Expiration:        time.Duration(rateLimitCfg.ExpSec()) * time.Second,
        LimiterMiddleware: limiter.SlidingWindow{},
        Storage:           cacheStorage,
    }))
    server.Use(encryptcookie.New(encryptcookie.Config{
        Key: cfg.CookieSecret(),
    }))
    server.App.Use(cors.New(cors.Config{
        AllowOrigins:     "*",
        AllowMethods:     "GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD",
        AllowHeaders:     "Accept,Authorization,Content-Type",
        AllowCredentials: false, // credentials require explicit origins
        MaxAge:           300,
    }))
    logger.Info("Finished loading common middlewares")

    return server
}

Registering the routes

Just create a routes.go file inside the server directory with a RegisterFiberRoutes method:

package server

func (s *FiberServer) RegisterFiberRoutes() {
    s.routes.HealthRoutes(s.App)
}

Logging

For logging we will need to create a default logger initially then add the configuration when they are loaded on startup, on a logger.go file:

package server

import (
    "log/slog"
    "os"

    "github.com/tugascript/devlogs/idp/internal/config"
)

func DefaultLogger() *slog.Logger {
    return slog.New(slog.NewJSONHandler(
        os.Stdout,
        &slog.HandlerOptions{
            Level: slog.LevelInfo,
        },
    ))
}

func ConfigLogger(cfg config.LoggerConfig) *slog.Logger {
    logLevel := slog.LevelInfo

    if cfg.IsDebug() {
        logLevel = slog.LevelDebug
    }

    logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level: logLevel,
    }))

    if cfg.Env() == "production" {
        logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
            Level: logLevel,
        }))
    }

    return logger.With("service", cfg.ServiceName())
}

Running the app

On the cmd/api folder's main.go file just set-up everything and register the routes:

package main

import (
    "context"
    "fmt"
    "log/slog"
    "os/signal"
    "runtime"
    "syscall"
    "time"

    "github.com/tugascript/devlogs/idp/internal/config"
    "github.com/tugascript/devlogs/idp/internal/server"
)

func gracefulShutdown(
    logger *slog.Logger,
    fiberServer *server.FiberServer,
    done chan bool,
) {
    // Create context that listens for the interrupt signal from the OS.
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // Listen for the interrupt signal.
    <-ctx.Done()

    logger.InfoContext(ctx, "shutting down gracefully, press Ctrl+C again to force")

    // The context is used to inform the server it has 5 seconds to finish
    // the request it is currently handling
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := fiberServer.ShutdownWithContext(ctx); err != nil {
        logger.ErrorContext(ctx, "Server forced to shutdown with error", "error", err)
    }

    logger.InfoContext(ctx, "Server exiting")

    // Notify the main goroutine that the shutdown is complete
    done <- true
}

func main() {
    logger := server.DefaultLogger()
    ctx := context.Background()
    logger.InfoContext(ctx, "Loading configuration...")
    cfg := config.NewConfig(logger, "./.env")

    logger = server.ConfigLogger(cfg.LoggerConfig())
    logger.InfoContext(ctx, "Setting GOMAXPROCS...", "maxProcs", cfg.MaxProcs())
    runtime.GOMAXPROCS(int(cfg.MaxProcs()))
    logger.InfoContext(ctx, "Finished setting GOMAXPROCS")

    logger.InfoContext(ctx, "Building server...")
    server := server.New(ctx, logger, cfg)
    logger.InfoContext(ctx, "Server built")

    server.RegisterFiberRoutes()

    // Create a done channel to signal when the shutdown is complete
    done := make(chan bool, 1)

    go func() {
        err := server.Listen(fmt.Sprintf(":%d", cfg.Port()))
        if err != nil {
            logger.ErrorContext(ctx, "http server error", "error", err)
            panic(fmt.Sprintf("http server error: %s", err))
        }
    }()

    // Run graceful shutdown in a separate goroutine
    go gracefulShutdown(logger, server, done)

    // Wait for the graceful shutdown to complete
    <-done
    logger.InfoContext(ctx, "Graceful shutdown complete.")
}

Conclusion

In this first article, we introduced the fiber framework, a express inspired go library, as well as how to set up our API with a MSC (model, service, controller) architecture with a centralized configuration.

This code is based on the current ongoing project found on the devlogs repository.

About the Author

Hey there! I am Afonso Barracha, your go-to econometrician who found his way into the world of back-end development with a soft spot for GraphQL. If you enjoyed reading this article, why not show some love by buying me a coffee?

Lately, I have been diving deep into more advanced subjects. As a result, I have switched from sharing my thoughts every week to posting once or twice a month. This way, I can make sure to bring you the highest quality content possible.

Do not miss out on any of my latest articles – follow me here on dev, LinkedIn or Instagram to stay updated. I would be thrilled to welcome you to our ever-growing community! See you around!