feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client

Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid,
at_hash, signed userinfo, RP-initiated and back-channel logout), managed
refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens,
DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592
registration, RFC 9207 iss, signing keyring with rotation.

State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct
backends, four dialect DDLs, conformance cases).

Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL.

PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse.

Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
This commit is contained in:
Hein
2026-10-01 14:42:12 +02:00
parent f54b707040
commit 640faeeeaf
51 changed files with 8682 additions and 1125 deletions
+4
View File
@@ -71,6 +71,9 @@ func New(db *sql.DB, cfg lookup.Config, opts Options) (*lookup.Provider, error)
OAuthUser: &oauthUserRouter{c: c,
proc: procedure.NewOAuthUsers(run, res.Procs),
direct: direct.NewOAuthUsers(base)},
OAuthGrant: &oauthGrantRouter{c: c,
proc: procedure.NewOAuthGrants(run, res.Procs),
direct: direct.NewOAuthGrants(base)},
Passkey: &passkeyRouter{c: c, proc: p, direct: direct.NewPasskey(base)},
TOTP: &totpRouter{c: c,
proc: procedure.NewTOTP(run, res.Procs),
@@ -90,6 +93,7 @@ func Failed(err error) *lookup.Provider {
Keys: &keysRouter{c: c},
OAuthClient: &oauthClientRouter{c: c},
OAuthUser: &oauthUserRouter{c: c},
OAuthGrant: &oauthGrantRouter{c: c},
Passkey: &passkeyRouter{c: c},
TOTP: &totpRouter{c: c},
Policy: &policyRouter{c: c},
@@ -113,6 +113,11 @@ func cleanup(t *testing.T, db *sql.DB, d dialect.Dialect, prefix string) {
like := prefix + "%"
for _, q := range []struct{ table, col string }{
{"oauth_codes", "code"},
{"oauth_consents", "client_id"},
{"oauth_refresh_tokens", "client_id"},
{"oauth_device_codes", "client_id"},
{"oauth_par_requests", "client_id"},
{"oauth_jti", "jti_key"},
{"oauth_clients", "client_id"},
{"token_blacklist", "token"},
{"sec_column_rules", "schema_name"},
@@ -154,3 +154,27 @@ func TestConformanceMSSQLContainer(t *testing.T) {
}
runOnServer(t, "sqlserver", dsn("cf_direct"), "mssql", lookup.Config{}, true)
}
// TestContainerLifecycle checks the start/stop plumbing the container tests rely on: the
// container comes up and accepts connections, and after stop it is gone (it runs with --rm).
func TestContainerLifecycle(t *testing.T) {
rt := containerRuntime(t)
port := startContainer(t, rt, "docker.io/library/postgres:16-alpine", "5432", map[string]string{"POSTGRES_PASSWORD": containerPassword})
waitReady(t, "pgx", fmt.Sprintf("postgres://postgres:%s@127.0.0.1:%s/postgres?sslmode=disable", containerPassword, port), 90*time.Second)
listed := func() string {
return run(t, 30*time.Second, rt, "ps", "-q", "--filter", "ancestor=docker.io/library/postgres:16-alpine")
}
id := listed()
if id == "" {
t.Fatal("container is not running after start")
}
run(t, time.Minute, rt, "stop", "-t", "2", id)
deadline := time.Now().Add(30 * time.Second)
for listed() != "" {
if time.Now().After(deadline) {
t.Fatal("container still present after stop")
}
time.Sleep(500 * time.Millisecond)
}
}
+147
View File
@@ -199,6 +199,22 @@ func (r *oauthClientRouter) Revoke(ctx context.Context, token string) error {
return st.Revoke(ctx, token)
}
func (r *oauthClientRouter) UpdateClient(ctx context.Context, client *sectypes.OAuthServerClient) error {
st, err := pick[lookup.OAuthClientStore](r.c, ctx, lookup.OpOAuthUpdateClient, r.c.procs.OAuthUpdateClient, r.proc, r.direct)
if err != nil {
return err
}
return st.UpdateClient(ctx, client)
}
func (r *oauthClientRouter) DeleteClient(ctx context.Context, clientID string) error {
st, err := pick[lookup.OAuthClientStore](r.c, ctx, lookup.OpOAuthDeleteClient, r.c.procs.OAuthDeleteClient, r.proc, r.direct)
if err != nil {
return err
}
return st.DeleteClient(ctx, clientID)
}
type oauthUserRouter struct {
c *chooser
proc, direct lookup.OAuthUserStore
@@ -394,3 +410,134 @@ func (r *policyRouter) RowSecurity(ctx context.Context, userRef any, schema, tab
}
return st.RowSecurity(ctx, userRef, schema, table)
}
type oauthGrantRouter struct {
c *chooser
proc, direct lookup.OAuthGrantStore
}
var _ lookup.OAuthGrantStore = (*oauthGrantRouter)(nil)
func (r *oauthGrantRouter) pick(ctx context.Context, op lookup.Op, proc string) (lookup.OAuthGrantStore, error) {
return pick[lookup.OAuthGrantStore](r.c, ctx, op, proc, r.proc, r.direct)
}
func (r *oauthGrantRouter) SaveConsent(ctx context.Context, c lookup.Consent) error {
st, err := r.pick(ctx, lookup.OpOAuthSaveConsent, r.c.procs.OAuthSaveConsent)
if err != nil {
return err
}
return st.SaveConsent(ctx, c)
}
func (r *oauthGrantRouter) GetConsent(ctx context.Context, userID int, clientID string) (*lookup.Consent, error) {
st, err := r.pick(ctx, lookup.OpOAuthGetConsent, r.c.procs.OAuthGetConsent)
if err != nil {
return nil, err
}
return st.GetConsent(ctx, userID, clientID)
}
func (r *oauthGrantRouter) RevokeConsent(ctx context.Context, userID int, clientID string) error {
st, err := r.pick(ctx, lookup.OpOAuthRevokeConsent, r.c.procs.OAuthRevokeConsent)
if err != nil {
return err
}
return st.RevokeConsent(ctx, userID, clientID)
}
func (r *oauthGrantRouter) SaveRefresh(ctx context.Context, t lookup.RefreshToken) error {
st, err := r.pick(ctx, lookup.OpOAuthSaveRefresh, r.c.procs.OAuthSaveRefresh)
if err != nil {
return err
}
return st.SaveRefresh(ctx, t)
}
func (r *oauthGrantRouter) RotateRefresh(ctx context.Context, oldHash string, next lookup.RefreshToken) (*lookup.RefreshToken, error) {
st, err := r.pick(ctx, lookup.OpOAuthRotateRefresh, r.c.procs.OAuthRotateRefresh)
if err != nil {
return nil, err
}
return st.RotateRefresh(ctx, oldHash, next)
}
func (r *oauthGrantRouter) PeekRefresh(ctx context.Context, hash string) (*lookup.RefreshToken, error) {
st, err := r.pick(ctx, lookup.OpOAuthPeekRefresh, r.c.procs.OAuthPeekRefresh)
if err != nil {
return nil, err
}
return st.PeekRefresh(ctx, hash)
}
func (r *oauthGrantRouter) RevokeRefreshFamily(ctx context.Context, familyID string) error {
st, err := r.pick(ctx, lookup.OpOAuthRevokeRefreshFamily, r.c.procs.OAuthRevokeRefreshFamily)
if err != nil {
return err
}
return st.RevokeRefreshFamily(ctx, familyID)
}
func (r *oauthGrantRouter) RevokeRefreshBySession(ctx context.Context, sessionToken string) error {
st, err := r.pick(ctx, lookup.OpOAuthRevokeRefreshByUser, r.c.procs.OAuthRevokeRefreshByUser)
if err != nil {
return err
}
return st.RevokeRefreshBySession(ctx, sessionToken)
}
func (r *oauthGrantRouter) CreateDevice(ctx context.Context, d lookup.DeviceCode) error {
st, err := r.pick(ctx, lookup.OpOAuthCreateDevice, r.c.procs.OAuthCreateDevice)
if err != nil {
return err
}
return st.CreateDevice(ctx, d)
}
func (r *oauthGrantRouter) DeviceByUserCode(ctx context.Context, userCode string) (*lookup.DeviceCode, error) {
st, err := r.pick(ctx, lookup.OpOAuthDeviceByUserCode, r.c.procs.OAuthDeviceByUserCode)
if err != nil {
return nil, err
}
return st.DeviceByUserCode(ctx, userCode)
}
func (r *oauthGrantRouter) DeviceDecide(ctx context.Context, userCode string, approve bool, userID int, sessionToken string) error {
st, err := r.pick(ctx, lookup.OpOAuthDeviceDecide, r.c.procs.OAuthDeviceDecide)
if err != nil {
return err
}
return st.DeviceDecide(ctx, userCode, approve, userID, sessionToken)
}
func (r *oauthGrantRouter) DevicePoll(ctx context.Context, deviceHash string) (*lookup.DeviceCode, error) {
st, err := r.pick(ctx, lookup.OpOAuthDevicePoll, r.c.procs.OAuthDevicePoll)
if err != nil {
return nil, err
}
return st.DevicePoll(ctx, deviceHash)
}
func (r *oauthGrantRouter) SavePushedRequest(ctx context.Context, req lookup.PushedRequest) error {
st, err := r.pick(ctx, lookup.OpOAuthSavePAR, r.c.procs.OAuthSavePAR)
if err != nil {
return err
}
return st.SavePushedRequest(ctx, req)
}
func (r *oauthGrantRouter) ConsumePushedRequest(ctx context.Context, requestURI string) (*lookup.PushedRequest, error) {
st, err := r.pick(ctx, lookup.OpOAuthConsumePAR, r.c.procs.OAuthConsumePAR)
if err != nil {
return nil, err
}
return st.ConsumePushedRequest(ctx, requestURI)
}
func (r *oauthGrantRouter) SeenJTI(ctx context.Context, key string, expires time.Time) (bool, error) {
st, err := r.pick(ctx, lookup.OpOAuthSeenJTI, r.c.procs.OAuthSeenJTI)
if err != nil {
return false, err
}
return st.SeenJTI(ctx, key, expires)
}