Contents

Implementing an oauth 2 server using Go

how to implement OAuth 2.0 in Go

Background

OAuth 2.0, is a de facto industry standard for online authentication and authorization. It is designed to allow a website or application to access resources hosted by other web apps on behalf of a user.

I’ve done a talk on this, here is the speaker deck primer to brush up on the main concepts. Additionally, it also touches on OIDC and JWT and how they relate to Oauth2. We’ll mainly be focusing on an implementation using Go.

To implement OAuth 2.0 using Go, one has a couple of options:

  • sign up for one of the OAuth Saas offerings
  • use an existing library/SDK
  • create a custom implementation following the RFC 6749 specification and its extensions.

Either option has its own pros and cons. Writing your own gives you more control and flexibility over the codebase, but it also requires more effort and time to ensure compliance and security. Using an existing library reduces time but at the cost of full control. In the search for a proper server SDK, I came across Fosite which is impressive.

Ory Fosite, as described by it’s authors, is a security-first OAuth 2.0 and OpenID Connect SDK for Go. It is developed by Ory, a company that provides cloud-native, open source API security solutions for infrastructure. The advantages of using it are its extensibility, flexibility and compliance with the OAuth 2.0 and OpenID Connect specifications.

Project set up

To set up the project, I’ve used a cookie cutter golang template. Use of cookiecutter is not required but I recommend looking into it and other project set up templates. It saves time and effort by automating the setup of projects and ensures consistency and quality across them by being opinionated on standards and best practices.

The template used can be found here: cookicutter golang.

The main libraries are:

  • fosite oauth2 sdk for implementing OAuth2 and OpenID Connect protocols
  • gorm for object-relational mapping and sqlite db for lightweight database management
  • gin for setting up the HTTP server and routing

For the full implementation check the repository

Initialize fosite in main.go and set up the http server

func main() {
	cfg := config.Config()

	r := gin.Default()

	secret := []byte("some-cool-secret-that-is-32bytes")

	conf := &fosite.Config{
		GlobalSecret: secret,
	}

	storage := interface{}

	provider := compose.Compose(
		conf,
		storage,
		compose.NewOAuth2HMACStrategy(conf),
		compose.OAuth2AuthorizeExplicitFactory,
		compose.OAuth2AuthorizeImplicitFactory,
		compose.OAuth2ClientCredentialsGrantFactory,
		compose.OAuth2RefreshTokenGrantFactory,
		compose.OAuth2TokenIntrospectionFactory,
		compose.OAuth2TokenRevocationFactory,
	)

	log.Info("starting server and listening on ", cfg.GetString("listen_address"))
	err := r.Run(cfg.GetString("listen_address"))
	if err != nil {
		os.Exit(1)
	}
}

Implement the storage storage := interface{}

The storage is the interface used for persisting data to a storage. Each factory (which represents the oauth flow) in the provider has a required storage interface.

We will implement the storage for the factories we have defined by creating a store package. Create a store which is an implementation of the fosite storage interface and define a constructor for the store which initializes the db and does migrations. This section contains a lot of code. Check store package for the full implementation

type Store struct {
	db *gorm.DB
}

func NewStore() *Store {
	db, err := gorm.Open(
		sqlite.Open(dsn),
		&gorm.Config{
			Logger: logger.Default.LogMode(logger.Error),
		},
	)
	if err != nil {
		log.Fatal("failed to connect database")
	}

	err = db.AutoMigrate(
		AccessToken{},
		AuthorizationCode{},
		Client{},
		ClientJWT{},
		User{},
		PKCE{},
		RefreshToken{},
		Session{},
	)
	if err != nil {
		log.Fatal("failed to run migrations:", err)
	}

	return &Store{
		db: db,
	}
}

An example of the access token storage interface implementation

type AccessToken struct {
	gorm.Model

	ID        string `gorm:"primarykey"`
	Active    bool
	Signature string `gorm:"unique"`

	RequestedAt       time.Time
	RequestedScopes   StringArray
	GrantedScopes     StringArray
	Form              datatypes.JSON
	RequestedAudience StringArray
	GrantedAudience   StringArray

	ClientID  string
	Client    Client
	SessionID string
	Session   Session
}

func (m Store) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
	var result AccessToken

	if err := m.db.Preload("Session.User").Preload(clause.Associations).Where(AccessToken{Signature: signature}).First(&result).Error; err != nil {
		return nil, fmt.Errorf("%w: %w", fosite.ErrNotFound, err)
	}

	var form url.Values
	err = json.Unmarshal(result.Form, &form)
	if err != nil {
		return nil, fmt.Errorf("error unmarshalling access token form attributes: %w", err)
	}

	rq := &fosite.Request{
		ID:                result.ID,
		RequestedAt:       result.RequestedAt,
		Client:            result.Client,
		RequestedScope:    fosite.Arguments(result.RequestedScopes),
		GrantedScope:      fosite.Arguments(result.GrantedScopes),
		Form:              form,
		Session:           &result.Session,
		RequestedAudience: fosite.Arguments(result.RequestedAudience),
		GrantedAudience:   fosite.Arguments(result.GrantedAudience),
	}

	return rq, nil
}

func (m Store) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) {
	if err := m.db.Where(&AccessToken{Signature: signature}).Delete(&AccessToken{}).Error; err != nil {
		return fmt.Errorf("failed to delete access token: %w", err)
	}

	return nil
}

Replace the storage interface storage := interface{} with our implementation

storage := store.NewStore()

Set up the necessary routes and handlers for the oauth flows

type Auth struct {
	provider fosite.OAuth2Provider
	store    *store.Store
}

func NewAuth(provider fosite.OAuth2Provider, store *store.Store) *Auth {

	return &Auth{
		provider: provider,
		store:    store,
	}
}

An implementation of the authorize handler where a user logs in using the authorization code grant. Fosite provides a set of helper utilities for use in the various handler implementations. The html templates are in a separate html package here.

func (a Auth) AuthorizeHandler(c *gin.Context) {
	ctx := c.Request.Context()

	ar, err := a.provider.NewAuthorizeRequest(ctx, c.Request)
	if err != nil {
		a.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
		return
	}

	params := Authorize{}
	err = c.Bind(&params)
	if err != nil {
		a.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
		return
	}

	// Check if username exists
	// if none serve the login page
	if params.Password == "" || params.Username == "" {
		c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
		params := html.LoginParams{
			Title:           "Login",
			RequestedScopes: ar.GetRequestedScopes(),
		}

		_ = html.Login(c.Writer, params)

		return
	}

	err = a.store.Authenticate(ctx, params.Username, params.Password)
	if err != nil {
		a.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
		return
	}

	// grant the scopes the user selected
	for _, scope := range params.Scopes {
		ar.GrantScope(scope)
	}

	user, err := a.store.GetUser(ctx, params.Username)
	if err != nil {
		a.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
		return
	}

	session, _ := store.NewSession(
		ctx,
		ar.GetClient().GetID(),
		user.ID,
		user.Username,
		user.Name,
		nil,
	)

	response, err := a.provider.NewAuthorizeResponse(ctx, ar, session)
	if err != nil {
		a.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
		return
	}

	a.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)

}

Add the handlers to server

	auth := internal.NewAuth(provider, storage)

	oauth2Routes := r.Group("/oauth2")

	oauth2Routes.GET("/authorize", auth.AuthorizeHandler)
	oauth2Routes.POST("/authorize", auth.AuthorizeHandler)
	oauth2Routes.POST("/token", auth.TokenHandler)
	oauth2Routes.POST("/revoke", auth.RevokeHandler)
	oauth2Routes.POST("/introspect", auth.IntrospectionHandler)

Testing the implementation

To run the implementation, we use Oauth tools, an OAuth 2 playground and Servio, a tool that allows you to expose a local development server to the internet

In the store implementation load some sample data in the database. In a real world implementation this would require having workflows to set this up.

	users := []User{
		{
			ID:       uuid.NewString(),
			Active:   true,
			Name:     "The Savant",
			Username: "ovl_doe",
			Password: "12345678",
		},
	}

	clients := []Client{
		{
			ID:     "alpha",
			Active: true,
			Secret: "$2a$10$IxMdI6d.LIRZPpSfEwNoeu4rY3FhDREsxFJXikcgdRRAStxUlsuEO", // "foobar"
			RotatedSecrets: []string{},
			Public: false,
			RedirectURIs: []string{
				"https://oauth.tools/callback/code",
			},
			Scopes: []string{
				"read", "write", "offline",
			},
			Audience: []string{},
			Grants: []string{
				"implicit", "refresh_token", "authorization_code", "client_credentials",
			},
			ResponseTypes: []string{
				"code", "token", "code token", "implicit",
			},
			TokenEndpointAuthMethod: "client_secret_basic",
		},
	}

	for _, client := range clients {
		err = db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, UpdateAll: true}).Create(&client).Error
		if err != nil {
			log.Fatal("failed to create client:", err)
		}
	}

	for _, user := range users {
		err = db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "username"}}, UpdateAll: true}).Create(&user).Error
		if err != nil {
			log.Fatal("failed to create client:", err)
		}
	}

Run the local server and expose it to the internet using serveo.

go run main.go
ssh -R 80:localhost:8000 serveo.net

Open the oauth tools website and create a new workspace.

  • In the settings, add the the paths we defined in our server using the generated servio URL.
  • Configure a client using the sample client above and the provided scopes.
  • Create a code flow to see it in action.

A short demo of the process can be seen below: