Skip to content

Minimal OP ​

This is the shortest path to a running OP. op.New refuses to start unless it gets three values: WithIssuer, WithStore and WithKeyset. The default grant set includes authorization_code, the grant that sends a user through a browser, and that grant needs two more: WithCookieKeys to seal the browser cookies and WithLoginFlow to say how users sign in. An OP that serves only client_credentials needs the first three alone. The snippets also set WithLogger, without which the OP discards its request-time logs and audit records (the one-time "stored password hash is not argon2id, set PrimaryPassword.LegacySchemes" warning is one of them) and warns about it at start-up. They declare no security profile: an OP built without WithProfile runs profile.OAuth21, so every authorization-code request needs PKCE (see Declaring a security profile).

Five values in, one handler out
A minimal OP takes five values — an issuer, a store, a keyset, cookie keys and a login flow — into op.New, which validates them and returns an ordinary HTTP handler.Issuerthe public https URL of the OPStorecodes · sessions · clients · chainsKeysetES256 signing keysCookie keysAES-256-GCM sealingLogin flowhow a user signs inop.New(…)validates the configuration up frontand refuses to start on a bad oneHTTP handlerhttp.Handlermount it, or serve it directly
Nothing here is a framework. What comes back is an http.Handler like any other, which is why the OP can sit anywhere in a router you already have.
go
package main

import (
  "crypto/ecdsa"
  "crypto/elliptic"
  "crypto/rand"
  "log"
  "log/slog"
  "net/http"

  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

func main() {
  // Ephemeral ECDSA P-256 (ES256) — replace with a vault / KMS key in
  // production. The Keyset is a slice of {KeyID, Signer}.
  priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
  cookieKey := make([]byte, 32) // AES-256-GCM
  if _, err := rand.Read(cookieKey); err != nil {
    log.Fatal(err)
  }

  st := inmem.New()
  handler, err := op.New(
    op.WithIssuer("http://127.0.0.1:8080"),
    op.WithLogger(slog.Default()),
    op.WithStore(st),
    op.WithKeyset(op.Keyset{{KeyID: "k1", Signer: priv}}),
    op.WithCookieKeys(cookieKey),
    op.WithLoginFlow(op.LoginFlow{
      Primary: op.PrimaryPassword{Store: st.UserPasswords()},
    }),
  )
  if err != nil {
    log.Fatal(err)
  }
  log.Fatal(http.ListenAndServe(":8080", handler))
}
go
package main

import (
  "crypto/ecdsa"
  "crypto/elliptic"
  "crypto/rand"
  "log"
  "log/slog"
  "net/http"

  "github.com/go-chi/chi/v5"
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

func main() {
  priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
  cookieKey := make([]byte, 32)
  if _, err := rand.Read(cookieKey); err != nil {
    log.Fatal(err)
  }

  st := inmem.New()
  handler, err := op.New(
    op.WithIssuer("http://127.0.0.1:8080"),
    op.WithLogger(slog.Default()),
    op.WithStore(st),
    op.WithKeyset(op.Keyset{{KeyID: "k1", Signer: priv}}),
    op.WithCookieKeys(cookieKey),
    op.WithLoginFlow(op.LoginFlow{
      Primary: op.PrimaryPassword{Store: st.UserPasswords()},
    }),
  )
  if err != nil {
    log.Fatal(err)
  }

  r := chi.NewRouter()
  r.Mount("/", handler)
  log.Fatal(http.ListenAndServe(":8080", r))
}
go
package main

import (
  "crypto/ecdsa"
  "crypto/elliptic"
  "crypto/rand"
  "log"
  "log/slog"
  "net/http"

  "github.com/gin-gonic/gin"
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

func main() {
  priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
  cookieKey := make([]byte, 32)
  if _, err := rand.Read(cookieKey); err != nil {
    log.Fatal(err)
  }

  st := inmem.New()
  handler, err := op.New(
    op.WithIssuer("http://127.0.0.1:8080"),
    op.WithLogger(slog.Default()),
    op.WithStore(st),
    op.WithKeyset(op.Keyset{{KeyID: "k1", Signer: priv}}),
    op.WithCookieKeys(cookieKey),
    op.WithLoginFlow(op.LoginFlow{
      Primary: op.PrimaryPassword{Store: st.UserPasswords()},
    }),
  )
  if err != nil {
    log.Fatal(err)
  }

  r := gin.New()
  r.Any("/*path", gin.WrapH(handler))
  log.Fatal(http.ListenAndServe(":8080", r))
}

Production caveats

  • Keys: generated at start-up here. Load them from a vault or KMS in production, so tokens survive a restart.
  • Store: in memory here. Use op/storeadapter/sql, op/storeadapter/dynamodb or composite in production. Redis is the volatile tier of a composite deployment, not a store on its own.
  • Issuer: a loopback http URL here so the curl commands below work. Use the public https URL in production.
  • Listener: plain HTTP here. Put a TLS-terminating ingress in front.
What you can try right now
  1. curl http://127.0.0.1:8080/.well-known/openid-configuration returns the discovery document. For an issuer without a path it sits at the host root, whatever the mount prefix is.
  2. curl http://127.0.0.1:8080/oidc/jwks returns the public keys that verify ID tokens. The default mount prefix is /oidc; op.WithMountPrefix("/") moves the endpoints to the root.
  3. /oidc/auth answers every request with an error until a client is registered. The next section adds one client and one user.

Add one client and one user ​

A relying party (RP) is an application that asks the OP to sign users in. The OP only accepts requests from clients it knows, so register the RP and give the password step someone to authenticate:

go
// extra imports: "context", "github.com/libraz/go-oidc-provider/op/store"
hash, err := op.HashPassword("demo")
if err != nil {
  log.Fatal(err)
}
st.PutUserWithPassword(context.Background(), &store.User{
  Subject: "demo-user",
  Claims:  map[string]any{"name": "Demo User", "email": "[email protected]"},
}, "demo", hash)

// pass to op.New alongside the options above
op.WithStaticClients(op.PublicClient{
  ID:           "demo-rp",
  RedirectURIs: []string{"http://127.0.0.1:9090/callback"},
  Scopes:       []string{"openid", "profile"},
})

PutUserWithPassword stores the hash as given, so a plaintext password would never match; hash it with op.HashPassword. A PublicClient has no secret and relies on PKCE, so the RP must send a code challenge. The upstream example below ships an RP that does.

Run the upstream example ​

sh
git clone https://github.com/libraz/go-oidc-provider.git
cd go-oidc-provider
(cd examples/01-minimal && GOWORK=off go run -tags example .)

01-minimal starts two listeners in one process: the OP on :8080 and a small RP on :9090. Open http://127.0.0.1:9090/, choose to log in, sign in as demo / demo, approve the consent screen, and the RP shows the verified ID token claims. The example keeps key generation (examples/internal/devkeys) and listener boilerplate (examples/internal/serve) out of main.go, so the file stays focused on op.New.

Next ​