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(¶ms)
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: