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).
http.Handler like any other, which is why the OP can sit anywhere in a router you already have.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))
}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))
}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/dynamodborcompositein production. Redis is the volatile tier of acompositedeployment, not a store on its own. - Issuer: a loopback
httpURL here so thecurlcommands below work. Use the publichttpsURL in production. - Listener: plain HTTP here. Put a TLS-terminating ingress in front.
What you can try right now
curl http://127.0.0.1:8080/.well-known/openid-configurationreturns the discovery document. For an issuer without a path it sits at the host root, whatever the mount prefix is.curl http://127.0.0.1:8080/oidc/jwksreturns the public keys that verify ID tokens. The default mount prefix is/oidc;op.WithMountPrefix("/")moves the endpoints to the root./oidc/authanswers 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:
// 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
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
- Required options: why these five.
- Mount on your router:
chi,ginand prefixes. - Use case: Minimal OP and RP pair: the same OP with a registered client and an authenticator wired in.