Implement OpenID Connect in Golang

What is OpenID Connect

OpenID Connect is an identity protocol built on top of the OAuth 2.0 framework. The distinction between OpenID Connect and OAuth 2.0 lies in their respective focuses. While OAuth 2.0 primarily deals with providing information about access, OpenID Connect is specifically designed for user authentication, focusing on user identity – essentially determining who the user is.

For a deeper understanding of OpenID Connect, I suggest reading the OpenID Connect specification at https://openid.net/errata-to-openid-connect-specifications-approved

Setting a project up

Open your favorite IDE for Golang (I personally use Visual Studio Code). Then open the directory where you intend to set-up your project. Create the ‘main.go’ file, and initialize ‘go.mod’ file by entering the command “go mod init example.com/my-oidc-test-project”

Install dependencies

  1. OAuth2 for Go – https://pkg.go.dev/golang.org/x/oauth2
  2. OpenID Connect support for Go – https://github.com/coreos/go-oidc
  3. Gin Web Framework – https://github.com/gin-gonic/gin

To install the dependencies mentioned above, enter the following command in the terminal.

go get -u golang.org/x/oauth2
go get -u github.com/coreos/go-oidc/v3/oidc

go get -u github.com/gin-gonic/gin

Host our server

package main

import ( “net/http”

“github.com/gin-gonic/gin”
)

func main() {
r := gin.Default()
r.GET(“/hello”, func(c *gin.Context) {

c.JSON(http.StatusOK,

gin.H{ “hello”: “world”,
})
})
r.Run(“:8082”) // listening on localhost:8082
}

Try to run it by typing “go run main.go” in your terminal.

We need SSL

To know how to configure SSL, you can read my blog post https://blog.aspiresys.pl/technology/what-is-nginx/

Configure OpenID Connect in Golang app

First of all we must create structure to keep our OIDC configuration. For now, we will hardcode it but in a real project you should use configuration file to keep application settings (e.g. use Viper https://github.com/spf13/viper)

func main() {
ctxBg := context.Background()
provider, err := oidc.NewProvider(ctxBg, “https://login.microsoftonline.com//v2.0”)
if err != nil {
// handle error
}

config := oauth2.Config{
ClientID: “”,
ClientSecret: “”, Endpoint: provider.Endpoint(),
RedirectURL: “https://localhost:8081/signin-oidc”,

this redirect Url must be set in your Identity Provider Portal

Scopes: []string{oidc.ScopeOpenID, “profile”, “email”}, //

scopes which you need

}

oidcConfig := 

&oidc.Config{ ClientID:

“<CLIENT_ID>”,

}

r := gin.Default()

// … here is our /ping endpoint handler

r.Run(“:8082”)

}

The next step is creating an endpoint to redirect a user to your identity provider. We create an endpoint “/login” for this purpose. When a user enters the “/login” endpoint, we create a state and a nonce. You can read about the both of them in the OpenID Connect specification. In general, you can save whatever you want in a state. Essentially, in a state, you can save information that might be needed when a user returns to your app. On the other hand, anonce must be unique for each session to ensure attackers cannot guess the value. Both state and nonce are saved in cookies.

func setCookie(w http.ResponseWriter, r *http.Request, name, value string) { c := &http.Cookie{
Name: name,
Value: value,
MaxAge: int(time.Hour.Seconds()), Secure: r.TLS != nil,
HttpOnly: true,
}
http.SetCookie(w, c)
}

func main() {

// …

r := gin.Default()
// … here is our /ping endpoint handler

r.GET(“/login”, func(ctx *gin.Context) { state, err := randString(32)
if err != nil {
http.Error(ctx.Writer, “Internal error”, http.StatusInternalServerError)
return
}
nonce, err := randString(32) if err != nil {
http.Error(ctx.Writer, “Internal error”, http.StatusInternalServerError)
return
}
setCookie(ctx.Writer, ctx.Request, “state”, state)

setCookie(ctx.Writer, ctx.Request, “nonce”, nonce)

http.Redirect(ctx.Writer, ctx.Request, config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)

})

r.Run(“:8082”)

}

You can try to run the app, and enter https://localhost:8081/login;in a browser. You should be redirected to your identity provider, and after typing in your credentials to sign-in, you will be redirected to your redirect_url;(in your case it is https://localhost:8081/signin-oidc )

Now you should see 404 page because we have not implemented this endpoint yet. Here’s your next step.

func main() {

// …

r.GET(“/login”, func(ctx *gin.Context) {

// …

})

r.GET(“/signin-oidc”, func(ctx *gin.Context) {

// compare saved state in cookie to received from IDP state, err := ctx.Cookie(“state”)

if err != nil {

http.Error(ctx.Writer, “state not found”, http.StatusBadRequest) return

}

if ctx.Request.URL.Query().Get(“state”) != state { http.Error(ctx.Writer, “state did not match”,

http.StatusBadRequest)

return

}

// exchange authorization code to access token oauth2Token, err := config.Exchange(ctx,

ctx.Request.URL.Query().Get(“code”)) if err != nil {

http.Error(ctx.Writer, “Failed to exchange token: ” + err.Error(), http.StatusInternalServerError)

return

}

rawIDToken, ok := oauth2Token.Extra(“id_token”).(string)

if !ok {

http.Error(ctx.Writer, “No id_token field in oauth2 token.”, http.StatusInternalServerError)

return

}

idToken, err := verifier.Verify(ctx, rawIDToken) if err != nil {

http.Error(ctx.Writer, “Failed to verify ID Token: ” + err.Error(), http.StatusInternalServerError)

return

}

// compare saved nonce in cookie to received from ID Token nonce, err := ctx.Cookie(“nonce”)

if err != nil {

http.Error(ctx.Writer, “nonce not found”, http.StatusBadRequest) return

}

if idToken.Nonce != nonce { http.Error(ctx.Writer, “nonce did not match”,

http.StatusBadRequest)

return

}

oauth2Token.AccessToken = “REPLACE TO NOT LEAK ACCESS TOKEN” resp := struct {

OAuth2Token *oauth2.Token IDTokenClaims *json.RawMessage

}{oauth2Token, new(json.RawMessage)}

if err := idToken.Claims(&resp.IDTokenClaims); err != nil { http.Error(ctx.Writer, err.Error(),

http.StatusInternalServerError) return

}

data, err := json.MarshalIndent(resp, “”, ” “) if err != nil {

http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)

return

}

ctx.Writer.Write(data)

})

r.Run(“:8082”)

}

Run the app ‘go run main. go” and try to sign-in as previously. However, now we should get a JSON containing access token and id token. I use Microsoft Identity Provider and got the below response. As we have a user identity, we can store it in the session cookie to know who talks to us. Of course, we will need a logout endpoint, however, it’s not part of this short introduction to OIDC in Golang.

{

“OAuth2Token”: {

“access_token”: “REPLACE TO NOT LEAK ACCESS TOKEN”,

“token_type”: “Bearer”,

“expiry”: “2023-10-07T15:04:07.532936335+02:00”

},

“IDTokenClaims”: { “aud”: “…”,

“iss”: “https://login.microsoftonline.com/…/v2.0”, “iat”: 1696679236,

“nbf”: 1696679236,

“exp”: 1696683136, “aio”: “…”,

“email”: “piotr.janowski@…”, “family_name”: “Janowski”, “given_name”: “Piotr”,

“name”: “Piotr Janowski”, “nonce”: “<NONCE>”, “oid”: “…”,

“preferred_username”: “piotr.janowski@…”, “rh”: “…”,

“sub”: “…”,

“tid”: “…”,

“upn”: “piotr.janowski@…”,

“uti”: “…”, “ver”: “2.0”

}

}

Conclusion

For developers, it’s much easier and safer to not keep user identities and passwords in their databases. Identity providers provide a secure and verifiable response that we don’t need to keep in whole. We only need a user ID from our external identity provider which is represented by ‘sub’ field in ID token.

Of course, there are more packages supporting you in OpenID Connect e.g. Ory fosite (The security first OAuth2 & OpenID Connect framework for Go https://github.com/ory/fosite), and Zitadel (OpenID Connect SDK (client and server) for Go https://github.com/zitadel/oidc#openid-connect-sdk-client-and- server-for-go). I encourage you to glance through them.

Tags: , , ,