Protect a Go API

Add OneiD to a Go API API with coreos/go-oidc, step by step.

View as Markdown

This quickstart adds OneiD to a api with coreos/go-oidc. Every file is complete and runs as it is.

Tip Using an AI coding agent? Give it this page as Markdown (add .md to the address) together with the Connector specification. See Build with AI coding agents.

Before you start

  • A OneiD address, for example https://YOUR_ONEID. The code below uses the demonstration instance https://auth.oltinid.com; replace it with your own.
  • An access token with the scope orders.read to test with.

1. Register the application

An API is not a client: it does not sign in. It needs an API scope that clients can request.

  1. Ask your OneiD administrator to create the API scope orders.read (or a scope named after your API) in the admin console.
  2. Ask for the scope to be allowed on the client that will call the API.
  3. Get an access token with that scope: from a server or browser application, or with the client credentials grant.

Note OneiD access tokens have no aud claim. The API accepts a token because it was issued by your OneiD, is unexpired and carries the scope the API requires. Give each API its own scopes. See Protect an API.

2. Install

go get github.com/coreos/go-oidc/v3/oidc

3. Add the code

main.go

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"slices"
	"strings"

	"github.com/coreos/go-oidc/v3/oidc"
)

func main() {
	// Reads the address of the signing keys from OneiD. The address ends with a slash, because
	// go-oidc compares it with the issuer name in the discovery document, and that name has one.
	provider, err := oidc.NewProvider(context.Background(), "https://auth.oltinid.com/")
	if err != nil {
		log.Fatal(err)
	}

	// Checks the signature, the issuer and the expiry. SkipClientIDCheck: OneiD tokens have no aud claim.
	verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})

	http.HandleFunc("GET /orders", func(w http.ResponseWriter, r *http.Request) {
		raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
		if !found {
			http.Error(w, "no access token", http.StatusUnauthorized)
			return
		}
		token, err := verifier.Verify(r.Context(), raw)
		if err != nil {
			http.Error(w, err.Error(), http.StatusUnauthorized)
			return
		}

		var claims struct {
			Scope string `json:"scope"` // one string with a space between the scopes
		}
		if err := token.Claims(&claims); err != nil || !slices.Contains(strings.Fields(claims.Scope), "orders.read") {
			http.Error(w, "the scope orders.read is required", http.StatusForbidden)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]string{"sub": token.Subject})
	})

	log.Println("http://localhost:4000/orders")
	log.Fatal(http.ListenAndServe("localhost:4000", nil))
}

4. Run it

Run: go run .

The API listens on http://localhost:4000. GET /orders answers 401 without a valid token, 403 without the scope orders.read, and otherwise returns the sub of the caller.

Test it with a token:

curl -i http://localhost:4000/orders
# HTTP/1.1 401 Unauthorized

curl -i http://localhost:4000/orders -H "Authorization: Bearer $ACCESS_TOKEN"
# HTTP/1.1 200 OK when the token carries orders.read, 403 when it does not

Checkpoint Without a token the API answers 401. With a valid token that carries orders.read it answers 200 and returns your sub; with a valid token without that scope it answers 403.

Common issues

  • 401 with a token that looks valid. Check the issuer: OneiD’s issuer ends with a slash (https://YOUR_ONEID/).
  • The API checks aud and rejects every token. OneiD access tokens carry no aud; remove the audience check and check the scope instead.
  • More errors and fixes: Errors and troubleshooting.

Learn more