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)
}
@@ -52,6 +52,12 @@ func Run(t *testing.T, env Env) {
t.Run("OAuthClientAndCodes", s.oauthClientAndCodes)
t.Run("OAuthIntrospectRevoke", s.oauthIntrospectRevoke)
t.Run("OAuthUsers", s.oauthUsers)
t.Run("OAuthClientMetadata", s.oauthClientMetadata)
t.Run("OAuthGrantConsent", s.oauthGrantConsent)
t.Run("OAuthGrantRefresh", s.oauthGrantRefresh)
t.Run("OAuthGrantDevice", s.oauthGrantDevice)
t.Run("OAuthGrantPAR", s.oauthGrantPAR)
t.Run("OAuthGrantJTI", s.oauthGrantJTI)
t.Run("Passkey", s.passkey)
t.Run("TOTP", s.totp)
t.Run("Policy", s.policy)
@@ -562,3 +568,305 @@ func (s *suite) policy(t *testing.T) {
}
func ptr[T any](v T) *T { return &v }
// --- OAuth server grant state -------------------------------------------------------------
func (s *suite) oauthClientMetadata(t *testing.T) {
st := s.Provider.OAuthClient
id := s.name("meta-client")
reg, err := st.RegisterClient(ctx, &sectypes.OAuthServerClient{
ClientID: id, RedirectURIs: []string{"https://app.example/cb"}, ClientName: "Meta",
PostLogoutRedirectURIs: []string{"https://app.example/bye"}, RequireConsent: true, FirstParty: false,
IDTokenSignedResponseAlg: "RS256", Contacts: []string{"ops@example.test"}, DPoPBoundAccessTokens: true,
})
if err != nil || reg.ClientID != id {
t.Fatalf("register: %+v %v", reg, err)
}
got, err := st.GetClient(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if !got.RequireConsent || !got.DPoPBoundAccessTokens || got.IDTokenSignedResponseAlg != "RS256" ||
len(got.PostLogoutRedirectURIs) != 1 || got.PostLogoutRedirectURIs[0] != "https://app.example/bye" ||
len(got.Contacts) != 1 {
t.Fatalf("metadata lost: %+v", got)
}
got.ClientName = "Renamed"
got.RequireConsent = false
got.RedirectURIs = []string{"https://app.example/cb", "https://app.example/cb2"}
if err := st.UpdateClient(ctx, got); err != nil {
t.Fatalf("update: %v", err)
}
again, err := st.GetClient(ctx, id)
if err != nil || again.ClientName != "Renamed" || again.RequireConsent || len(again.RedirectURIs) != 2 || !again.DPoPBoundAccessTokens {
t.Fatalf("after update: %+v %v", again, err)
}
if err := st.DeleteClient(ctx, id); err != nil {
t.Fatalf("delete: %v", err)
}
_, err = st.GetClient(ctx, id)
rejected(t, "deleted client", err)
// Code extras round-trip.
code := s.name("meta-code")
err = st.SaveCode(ctx, &sectypes.OAuthCode{
Code: code, ClientID: id, RedirectURI: "https://app.example/cb", CodeChallenge: "chal", CodeChallengeMethod: "S256",
SessionToken: "sess", Scopes: []string{"openid"}, ExpiresAt: time.Now().Add(time.Minute),
Nonce: "n-0S6", AuthTime: 1700000000, ACR: "urn:acr:1", AMR: []string{"pwd"}, UserID: 7,
Claims: map[string]any{"id_token": map[string]any{"email": nil}}, Resource: []string{"https://api.example"}, DPoPJKT: "jkt",
})
if err != nil {
t.Fatalf("save code: %v", err)
}
c, err := st.ExchangeCode(ctx, code)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if c.Nonce != "n-0S6" || c.AuthTime != 1700000000 || c.ACR != "urn:acr:1" || c.UserID != 7 || c.DPoPJKT != "jkt" ||
len(c.AMR) != 1 || len(c.Resource) != 1 || c.Claims["id_token"] == nil {
t.Fatalf("code extra lost: %+v", c)
}
}
func (s *suite) oauthGrantConsent(t *testing.T) {
g := s.Provider.OAuthGrant
client := s.name("consent-client")
if _, err := g.GetConsent(ctx, 1, client); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("missing consent: %v", err)
}
if err := g.SaveConsent(ctx, lookup.Consent{UserID: 1, ClientID: client, Scopes: []string{"openid", "email"}, ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("save: %v", err)
}
c, err := g.GetConsent(ctx, 1, client)
if err != nil || len(c.Scopes) != 2 {
t.Fatalf("get: %+v %v", c, err)
}
// Saving again replaces.
if err := g.SaveConsent(ctx, lookup.Consent{UserID: 1, ClientID: client, Scopes: []string{"openid"}, ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("resave: %v", err)
}
if c, err = g.GetConsent(ctx, 1, client); err != nil || len(c.Scopes) != 1 {
t.Fatalf("replaced: %+v %v", c, err)
}
// Another user is separate.
if _, err := g.GetConsent(ctx, 2, client); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("other user: %v", err)
}
// Expired consents are not returned.
if err := g.SaveConsent(ctx, lookup.Consent{UserID: 3, ClientID: client, Scopes: []string{"openid"}, ExpiresAt: time.Now().Add(-time.Minute)}); err != nil {
t.Fatalf("save expired: %v", err)
}
if _, err := g.GetConsent(ctx, 3, client); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("expired consent: %v", err)
}
if err := g.RevokeConsent(ctx, 1, client); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := g.GetConsent(ctx, 1, client); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("revoked consent: %v", err)
}
}
func (s *suite) oauthGrantRefresh(t *testing.T) {
g := s.Provider.OAuthGrant
client := s.name("refresh-client")
mk := func(n string, session string) lookup.RefreshToken {
return lookup.RefreshToken{TokenHash: s.name(n), FamilyID: s.name("fam-" + n), ClientID: client, UserID: 5,
SessionToken: session, Scopes: []string{"openid", "offline_access"},
Extra: map[string]any{"nonce": "abc"}, ExpiresAt: time.Now().Add(time.Hour)}
}
first := mk("r1", s.name("sess1"))
if err := g.SaveRefresh(ctx, first); err != nil {
t.Fatalf("save: %v", err)
}
peek, err := g.PeekRefresh(ctx, first.TokenHash)
if err != nil || peek.UserID != 5 || peek.ClientID != client || len(peek.Scopes) != 2 || peek.Extra["nonce"] != "abc" {
t.Fatalf("peek: %+v %v", peek, err)
}
if _, err := g.PeekRefresh(ctx, s.name("nope")); !errors.Is(err, lookup.ErrRefreshInvalid) {
t.Fatalf("peek unknown: %v", err)
}
next := lookup.RefreshToken{TokenHash: s.name("r2"), ExpiresAt: time.Now().Add(time.Hour), Scopes: []string{"openid"}}
old, err := g.RotateRefresh(ctx, first.TokenHash, next)
if err != nil || old.FamilyID != first.FamilyID || old.SessionToken != first.SessionToken {
t.Fatalf("rotate: %+v %v", old, err)
}
// The new token belongs to the same family, client and user.
n, err := g.PeekRefresh(ctx, next.TokenHash)
if err != nil || n.FamilyID != first.FamilyID || n.ClientID != client || n.UserID != 5 || n.SessionToken != first.SessionToken {
t.Fatalf("next: %+v %v", n, err)
}
// The consumed token is still visible to Peek, so that presenting it reaches RotateRefresh.
if _, err := g.PeekRefresh(ctx, first.TokenHash); err != nil {
t.Fatalf("peek consumed: %v", err)
}
// Presenting the consumed token again is reuse: the family (including the new token) dies.
third := lookup.RefreshToken{TokenHash: s.name("r3"), ExpiresAt: time.Now().Add(time.Hour)}
reused, err := g.RotateRefresh(ctx, first.TokenHash, third)
if !errors.Is(err, lookup.ErrRefreshReused) || reused == nil || reused.FamilyID != first.FamilyID {
t.Fatalf("reuse: %+v %v", reused, err)
}
if _, err := g.PeekRefresh(ctx, next.TokenHash); !errors.Is(err, lookup.ErrRefreshInvalid) {
t.Fatalf("family survived reuse: %v", err)
}
if _, err := g.RotateRefresh(ctx, next.TokenHash, lookup.RefreshToken{TokenHash: s.name("r4"), ExpiresAt: time.Now().Add(time.Hour)}); !errors.Is(err, lookup.ErrRefreshInvalid) {
t.Fatalf("rotate revoked: %v", err)
}
if _, err := g.PeekRefresh(ctx, third.TokenHash); err == nil {
t.Fatal("a rejected rotation must not store the next token")
}
// Expired tokens cannot rotate.
exp := mk("rexp", s.name("sess2"))
exp.ExpiresAt = time.Now().Add(-time.Minute)
if err := g.SaveRefresh(ctx, exp); err != nil {
t.Fatalf("save expired: %v", err)
}
if _, err := g.RotateRefresh(ctx, exp.TokenHash, lookup.RefreshToken{TokenHash: s.name("rexp2"), ExpiresAt: time.Now().Add(time.Hour)}); !errors.Is(err, lookup.ErrRefreshInvalid) {
t.Fatalf("rotate expired: %v", err)
}
// Revoking by family and by session.
fam := mk("rfam", s.name("sess3"))
if err := g.SaveRefresh(ctx, fam); err != nil {
t.Fatal(err)
}
if err := g.RevokeRefreshFamily(ctx, fam.FamilyID); err != nil {
t.Fatalf("revoke family: %v", err)
}
if _, err := g.PeekRefresh(ctx, fam.TokenHash); !errors.Is(err, lookup.ErrRefreshInvalid) {
t.Fatalf("family revoked: %v", err)
}
bs := mk("rsess", s.name("sess4"))
if err := g.SaveRefresh(ctx, bs); err != nil {
t.Fatal(err)
}
if err := g.RevokeRefreshBySession(ctx, bs.SessionToken); err != nil {
t.Fatalf("revoke session: %v", err)
}
if _, err := g.PeekRefresh(ctx, bs.TokenHash); !errors.Is(err, lookup.ErrRefreshInvalid) {
t.Fatalf("session revoked: %v", err)
}
}
func (s *suite) oauthGrantDevice(t *testing.T) {
g := s.Provider.OAuthGrant
client := s.name("device-client")
mk := func(n string) lookup.DeviceCode {
return lookup.DeviceCode{DeviceHash: s.name("dh-" + n), UserCode: strings.ToUpper(s.name("uc-" + n)), ClientID: client,
Scopes: []string{"openid"}, Interval: 1, ExpiresAt: time.Now().Add(time.Minute)}
}
// pending -> approved
d := mk("a")
if err := g.CreateDevice(ctx, d); err != nil {
t.Fatalf("create: %v", err)
}
got, err := g.DeviceByUserCode(ctx, strings.ToLower(d.UserCode)) // user codes are case-insensitive
if err != nil || got.ClientID != client || got.DeviceHash != d.DeviceHash || len(got.Scopes) != 1 {
t.Fatalf("by user code: %+v %v", got, err)
}
if _, err := g.DevicePoll(ctx, d.DeviceHash); !errors.Is(err, lookup.ErrDevicePending) {
t.Fatalf("first poll: %v", err)
}
if _, err := g.DevicePoll(ctx, d.DeviceHash); !errors.Is(err, lookup.ErrDeviceSlowDown) {
t.Fatalf("immediate re-poll: %v", err)
}
if err := g.DeviceDecide(ctx, d.UserCode, true, 9, s.name("dsess")); err != nil {
t.Fatalf("approve: %v", err)
}
if _, err := g.DeviceByUserCode(ctx, d.UserCode); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("decided code still pending: %v", err)
}
if err := g.DeviceDecide(ctx, d.UserCode, true, 9, "x"); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("second decision: %v", err)
}
time.Sleep(1100 * time.Millisecond)
done, err := g.DevicePoll(ctx, d.DeviceHash)
if err != nil || done.UserID != 9 || done.SessionToken != s.name("dsess") || done.ClientID != client {
t.Fatalf("approved poll: %+v %v", done, err)
}
if _, err := g.DevicePoll(ctx, d.DeviceHash); !errors.Is(err, lookup.ErrDeviceExpired) {
t.Fatalf("consumed code: %v", err)
}
// denied
dd := mk("d")
if err := g.CreateDevice(ctx, dd); err != nil {
t.Fatal(err)
}
if err := g.DeviceDecide(ctx, dd.UserCode, false, 0, ""); err != nil {
t.Fatalf("deny: %v", err)
}
if _, err := g.DevicePoll(ctx, dd.DeviceHash); !errors.Is(err, lookup.ErrDeviceDenied) {
t.Fatalf("denied poll: %v", err)
}
// expired
de := mk("e")
de.ExpiresAt = time.Now().Add(-time.Second)
if err := g.CreateDevice(ctx, de); err != nil {
t.Fatal(err)
}
if _, err := g.DevicePoll(ctx, de.DeviceHash); !errors.Is(err, lookup.ErrDeviceExpired) {
t.Fatalf("expired poll: %v", err)
}
if _, err := g.DeviceByUserCode(ctx, de.UserCode); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("expired by user code: %v", err)
}
if _, err := g.DevicePoll(ctx, s.name("unknown")); !errors.Is(err, lookup.ErrDeviceExpired) {
t.Fatalf("unknown poll: %v", err)
}
}
func (s *suite) oauthGrantPAR(t *testing.T) {
g := s.Provider.OAuthGrant
uri := "urn:ietf:params:oauth:request_uri:" + s.name("par")
if len(uri) > 255 {
t.Fatal("test request_uri too long")
}
req := lookup.PushedRequest{RequestURI: uri, ClientID: s.name("par-client"),
Params: map[string]string{"redirect_uri": "https://app.example/cb", "scope": "openid"}, ExpiresAt: time.Now().Add(time.Minute)}
if err := g.SavePushedRequest(ctx, req); err != nil {
t.Fatalf("save: %v", err)
}
got, err := g.ConsumePushedRequest(ctx, uri)
if err != nil || got.ClientID != req.ClientID || got.Params["scope"] != "openid" {
t.Fatalf("consume: %+v %v", got, err)
}
if _, err := g.ConsumePushedRequest(ctx, uri); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("second consume: %v", err)
}
exp := lookup.PushedRequest{RequestURI: uri + "-exp", ClientID: s.name("par-client"), Params: map[string]string{"a": "b"}, ExpiresAt: time.Now().Add(-time.Minute)}
if err := g.SavePushedRequest(ctx, exp); err != nil {
t.Fatal(err)
}
if _, err := g.ConsumePushedRequest(ctx, exp.RequestURI); !errors.Is(err, lookup.ErrNotFound) {
t.Fatalf("expired consume: %v", err)
}
}
func (s *suite) oauthGrantJTI(t *testing.T) {
g := s.Provider.OAuthGrant
key := s.name("jti")
seen, err := g.SeenJTI(ctx, key, time.Now().Add(time.Minute))
if err != nil || seen {
t.Fatalf("first: %v %v", seen, err)
}
seen, err = g.SeenJTI(ctx, key, time.Now().Add(time.Minute))
if err != nil || !seen {
t.Fatalf("replay: %v %v", seen, err)
}
// An expired entry is forgotten.
old := s.name("jti-old")
if _, err := g.SeenJTI(ctx, old, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
seen, err = g.SeenJTI(ctx, old, time.Now().Add(time.Minute))
if err != nil || seen {
t.Fatalf("after expiry: %v %v", seen, err)
}
}
+384 -7
View File
@@ -1765,8 +1765,10 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
is_active BOOLEAN DEFAULT true,
metadata jsonb, -- every other RFC 7591 field (see sectypes.OAuthServerClient)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS metadata jsonb;
-- oauth_codes: short-lived authorization codes (for multi-instance deployments)
-- Note: client_id is stored without a foreign key so codes can be persisted even
@@ -1783,8 +1785,10 @@ CREATE TABLE IF NOT EXISTS oauth_codes (
refresh_token TEXT,
scopes TEXT[],
expires_at TIMESTAMP NOT NULL,
extra jsonb, -- nonce, auth_time, acr, claims, user_id, dpop_jkt ... (see sectypes.OAuthCode)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE oauth_codes ADD COLUMN IF NOT EXISTS extra jsonb;
CREATE INDEX IF NOT EXISTS idx_oauth_codes_code ON oauth_codes(code);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
@@ -1801,7 +1805,7 @@ DECLARE
BEGIN
v_client_id := p_request->>'client_id';
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method)
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method, metadata)
VALUES (
v_client_id,
ARRAY(SELECT jsonb_array_elements_text(CASE WHEN jsonb_typeof(p_request->'redirect_uris') = 'array' THEN p_request->'redirect_uris' ELSE '[]'::jsonb END)),
@@ -1809,9 +1813,10 @@ BEGIN
CASE WHEN jsonb_typeof(p_request->'grant_types') = 'array' AND jsonb_array_length(p_request->'grant_types') > 0 THEN ARRAY(SELECT jsonb_array_elements_text(p_request->'grant_types')) ELSE ARRAY['authorization_code'] END,
CASE WHEN jsonb_typeof(p_request->'allowed_scopes') = 'array' AND jsonb_array_length(p_request->'allowed_scopes') > 0 THEN ARRAY(SELECT jsonb_array_elements_text(p_request->'allowed_scopes')) ELSE ARRAY['openid','profile','email'] END,
NULLIF(p_request->>'client_secret_hash', ''),
COALESCE(NULLIF(p_request->>'token_endpoint_auth_method', ''), 'none')
COALESCE(NULLIF(p_request->>'token_endpoint_auth_method', ''), 'none'),
NULLIF(p_request - ARRAY['client_id','redirect_uris','client_name','grant_types','allowed_scopes','client_secret_hash','token_endpoint_auth_method'], '{}'::jsonb)
)
RETURNING to_jsonb(oauth_clients.*) INTO v_row;
RETURNING (to_jsonb(oauth_clients.*) - 'metadata') || COALESCE(metadata, '{}'::jsonb) INTO v_row;
RETURN QUERY SELECT true, null::text, v_row;
EXCEPTION WHEN OTHERS THEN
@@ -1825,7 +1830,7 @@ LANGUAGE plpgsql AS $$
DECLARE
v_row jsonb;
BEGIN
SELECT to_jsonb(oauth_clients.*)
SELECT (to_jsonb(oauth_clients.*) - 'metadata') || COALESCE(metadata, '{}'::jsonb)
INTO v_row
FROM oauth_clients
WHERE client_id = p_client_id AND is_active = true;
@@ -1842,7 +1847,7 @@ CREATE OR REPLACE FUNCTION resolvespec_oauth_save_code(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text)
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO oauth_codes (code, client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes, expires_at)
INSERT INTO oauth_codes (code, client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes, expires_at, extra)
VALUES (
p_request->>'code',
p_request->>'client_id',
@@ -1853,7 +1858,8 @@ BEGIN
p_request->>'session_token',
p_request->>'refresh_token',
ARRAY(SELECT jsonb_array_elements_text(CASE WHEN jsonb_typeof(p_request->'scopes') = 'array' THEN p_request->'scopes' ELSE '[]'::jsonb END)),
(p_request->>'expires_at')::timestamptz::timestamp
(p_request->>'expires_at')::timestamptz::timestamp,
NULLIF(p_request - ARRAY['code','client_id','redirect_uri','client_state','code_challenge','code_challenge_method','session_token','refresh_token','scopes','expires_at'], '{}'::jsonb)
);
RETURN QUERY SELECT true, null::text;
@@ -1879,7 +1885,7 @@ BEGIN
'session_token', session_token,
'refresh_token', refresh_token,
'scopes', to_jsonb(scopes)
) INTO v_row;
) || COALESCE(extra, '{}'::jsonb) INTO v_row;
IF v_row IS NULL THEN
RETURN QUERY SELECT false, 'invalid or expired code'::text, null::jsonb;
@@ -1930,3 +1936,374 @@ BEGIN
RETURN QUERY SELECT true, null::text;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_update_client(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text)
LANGUAGE plpgsql AS $$
DECLARE
v_rows int;
BEGIN
UPDATE oauth_clients SET
redirect_uris = ARRAY(SELECT jsonb_array_elements_text(CASE WHEN jsonb_typeof(p_request->'redirect_uris') = 'array' THEN p_request->'redirect_uris' ELSE '[]'::jsonb END)),
client_name = p_request->>'client_name',
grant_types = CASE WHEN jsonb_typeof(p_request->'grant_types') = 'array' AND jsonb_array_length(p_request->'grant_types') > 0 THEN ARRAY(SELECT jsonb_array_elements_text(p_request->'grant_types')) ELSE grant_types END,
allowed_scopes = CASE WHEN jsonb_typeof(p_request->'allowed_scopes') = 'array' AND jsonb_array_length(p_request->'allowed_scopes') > 0 THEN ARRAY(SELECT jsonb_array_elements_text(p_request->'allowed_scopes')) ELSE allowed_scopes END,
client_secret_hash = NULLIF(p_request->>'client_secret_hash', ''),
token_endpoint_auth_method = COALESCE(NULLIF(p_request->>'token_endpoint_auth_method', ''), token_endpoint_auth_method),
metadata = NULLIF(p_request - ARRAY['client_id','redirect_uris','client_name','grant_types','allowed_scopes','client_secret_hash','token_endpoint_auth_method'], '{}'::jsonb)
WHERE client_id = p_request->>'client_id' AND is_active = true;
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RETURN QUERY SELECT false, 'client not found'::text;
ELSE
RETURN QUERY SELECT true, null::text;
END IF;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT false, SQLERRM;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_delete_client(p_client_id text)
RETURNS TABLE(p_success bool, p_error text)
LANGUAGE plpgsql AS $$
BEGIN
UPDATE oauth_clients SET is_active = false WHERE client_id = p_client_id;
RETURN QUERY SELECT true, null::text;
END;
$$;
-- ============================================
-- OAuth2 Server grant state (consents, refresh tokens, device codes, PAR, replay cache)
-- ============================================
-- Procedure-backend tables use jsonb for scopes/extra/params. Every procedure takes one jsonb
-- request and returns (p_success, p_error, p_data). p_error carries a stable code for the
-- failures the Go side maps to errors: not_found, refresh_invalid, refresh_reused,
-- device_pending, device_slowdown, device_denied, device_expired.
CREATE TABLE IF NOT EXISTS oauth_consents (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
client_id VARCHAR(255) NOT NULL,
scopes jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_consents_user_client ON oauth_consents(user_id, client_id);
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
id SERIAL PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL UNIQUE, -- sha256 hex of the raw refresh token
family_id VARCHAR(64) NOT NULL,
client_id VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL,
session_token VARCHAR(255),
scopes jsonb,
extra jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
revoked_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_family ON oauth_refresh_tokens(family_id);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_session ON oauth_refresh_tokens(session_token);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_expires ON oauth_refresh_tokens(expires_at);
CREATE TABLE IF NOT EXISTS oauth_device_codes (
id SERIAL PRIMARY KEY,
device_hash VARCHAR(64) NOT NULL UNIQUE,
user_code VARCHAR(32) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
scopes jsonb,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
user_id INTEGER,
session_token VARCHAR(255),
poll_interval INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
last_polled_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_device_expires ON oauth_device_codes(expires_at);
CREATE TABLE IF NOT EXISTS oauth_par_requests (
id SERIAL PRIMARY KEY,
request_uri VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
params jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_par_expires ON oauth_par_requests(expires_at);
CREATE TABLE IF NOT EXISTS oauth_jti (
id SERIAL PRIMARY KEY,
jti_key VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_jti_expires ON oauth_jti(expires_at);
CREATE OR REPLACE FUNCTION resolvespec_oauth_save_consent(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM oauth_consents
WHERE user_id = (p_request->>'user_id')::int AND client_id = p_request->>'client_id';
INSERT INTO oauth_consents (user_id, client_id, scopes, expires_at)
VALUES ((p_request->>'user_id')::int, p_request->>'client_id', COALESCE(p_request->'scopes', '[]'::jsonb),
(p_request->>'expires_at')::timestamptz::timestamp);
RETURN QUERY SELECT true, null::text, null::jsonb;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT false, SQLERRM, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_get_consent(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
v_row jsonb;
BEGIN
SELECT jsonb_build_object('user_id', user_id, 'client_id', client_id, 'scopes', COALESCE(scopes, '[]'::jsonb), 'expires_at', expires_at)
INTO v_row
FROM oauth_consents
WHERE user_id = (p_request->>'user_id')::int AND client_id = p_request->>'client_id' AND expires_at > now();
IF v_row IS NULL THEN
RETURN QUERY SELECT false, 'not_found'::text, null::jsonb;
ELSE
RETURN QUERY SELECT true, null::text, v_row;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_revoke_consent(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM oauth_consents
WHERE user_id = (p_request->>'user_id')::int AND client_id = p_request->>'client_id';
RETURN QUERY SELECT true, null::text, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_save_refresh(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO oauth_refresh_tokens (token_hash, family_id, client_id, user_id, session_token, scopes, extra, expires_at)
VALUES (p_request->>'token_hash', p_request->>'family_id', p_request->>'client_id', (p_request->>'user_id')::int,
p_request->>'session_token', p_request->'scopes', p_request->'extra',
(p_request->>'expires_at')::timestamptz::timestamp);
RETURN QUERY SELECT true, null::text, null::jsonb;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT false, SQLERRM, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_rotate_refresh(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
r oauth_refresh_tokens%ROWTYPE;
v_next jsonb := p_request->'next';
v_old jsonb;
BEGIN
SELECT * INTO r FROM oauth_refresh_tokens WHERE token_hash = p_request->>'old_hash' FOR UPDATE;
IF NOT FOUND OR r.revoked_at IS NOT NULL OR r.expires_at <= now() THEN
RETURN QUERY SELECT false, 'refresh_invalid'::text, null::jsonb;
RETURN;
END IF;
v_old := jsonb_build_object('token_hash', r.token_hash, 'family_id', r.family_id, 'client_id', r.client_id,
'user_id', r.user_id, 'session_token', r.session_token,
'scopes', COALESCE(r.scopes, '[]'::jsonb), 'extra', COALESCE(r.extra, '{}'::jsonb),
'expires_at', r.expires_at);
IF r.used_at IS NOT NULL THEN
-- A rotated token came back: revoke the whole family. Returning (not raising) keeps the revoke.
UPDATE oauth_refresh_tokens SET revoked_at = now() WHERE family_id = r.family_id AND revoked_at IS NULL;
RETURN QUERY SELECT false, 'refresh_reused'::text, v_old;
RETURN;
END IF;
UPDATE oauth_refresh_tokens SET used_at = now() WHERE id = r.id;
INSERT INTO oauth_refresh_tokens (token_hash, family_id, client_id, user_id, session_token, scopes, extra, expires_at)
VALUES (v_next->>'token_hash', r.family_id, r.client_id, r.user_id,
COALESCE(NULLIF(v_next->>'session_token', ''), r.session_token),
v_next->'scopes', v_next->'extra', (v_next->>'expires_at')::timestamptz::timestamp);
RETURN QUERY SELECT true, null::text, v_old;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT false, SQLERRM, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_peek_refresh(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
v_row jsonb;
BEGIN
SELECT jsonb_build_object('token_hash', token_hash, 'family_id', family_id, 'client_id', client_id,
'user_id', user_id, 'session_token', session_token,
'scopes', COALESCE(scopes, '[]'::jsonb), 'extra', COALESCE(extra, '{}'::jsonb),
'expires_at', expires_at)
INTO v_row
FROM oauth_refresh_tokens
WHERE token_hash = p_request->>'token_hash' AND revoked_at IS NULL AND expires_at > now();
IF v_row IS NULL THEN
RETURN QUERY SELECT false, 'refresh_invalid'::text, null::jsonb;
ELSE
RETURN QUERY SELECT true, null::text, v_row;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_revoke_refresh_family(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
UPDATE oauth_refresh_tokens SET revoked_at = now() WHERE family_id = p_request->>'family_id' AND revoked_at IS NULL;
RETURN QUERY SELECT true, null::text, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_revoke_refresh_session(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
UPDATE oauth_refresh_tokens SET revoked_at = now() WHERE session_token = p_request->>'session_token' AND revoked_at IS NULL;
RETURN QUERY SELECT true, null::text, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_create_device(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO oauth_device_codes (device_hash, user_code, client_id, scopes, status, poll_interval, expires_at)
VALUES (p_request->>'device_hash', upper(p_request->>'user_code'), p_request->>'client_id', p_request->'scopes',
COALESCE(NULLIF(p_request->>'status', ''), 'pending'), COALESCE((p_request->>'interval')::int, 5),
(p_request->>'expires_at')::timestamptz::timestamp);
RETURN QUERY SELECT true, null::text, null::jsonb;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT false, SQLERRM, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_device_by_user_code(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
v_row jsonb;
BEGIN
SELECT jsonb_build_object('device_hash', device_hash, 'user_code', user_code, 'client_id', client_id,
'scopes', COALESCE(scopes, '[]'::jsonb), 'status', status, 'interval', poll_interval,
'expires_at', expires_at)
INTO v_row
FROM oauth_device_codes
WHERE user_code = upper(p_request->>'user_code') AND status = 'pending' AND expires_at > now();
IF v_row IS NULL THEN
RETURN QUERY SELECT false, 'not_found'::text, null::jsonb;
ELSE
RETURN QUERY SELECT true, null::text, v_row;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_device_decide(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
v_rows int;
v_approve boolean := COALESCE((p_request->>'approve')::boolean, false);
BEGIN
UPDATE oauth_device_codes
SET status = CASE WHEN v_approve THEN 'approved' ELSE 'denied' END,
user_id = CASE WHEN v_approve THEN (p_request->>'user_id')::int ELSE user_id END,
session_token = CASE WHEN v_approve THEN p_request->>'session_token' ELSE session_token END
WHERE user_code = upper(p_request->>'user_code') AND status = 'pending' AND expires_at > now();
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RETURN QUERY SELECT false, 'not_found'::text, null::jsonb;
ELSE
RETURN QUERY SELECT true, null::text, null::jsonb;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_device_poll(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
d oauth_device_codes%ROWTYPE;
v_slow boolean;
BEGIN
SELECT * INTO d FROM oauth_device_codes WHERE device_hash = p_request->>'device_hash' FOR UPDATE;
IF NOT FOUND THEN
RETURN QUERY SELECT false, 'device_expired'::text, null::jsonb;
RETURN;
END IF;
IF d.expires_at <= now() THEN
DELETE FROM oauth_device_codes WHERE id = d.id;
RETURN QUERY SELECT false, 'device_expired'::text, null::jsonb;
RETURN;
END IF;
v_slow := d.last_polled_at IS NOT NULL AND (now() - d.last_polled_at) < make_interval(secs => d.poll_interval);
UPDATE oauth_device_codes SET last_polled_at = now() WHERE id = d.id;
IF v_slow THEN
RETURN QUERY SELECT false, 'device_slowdown'::text, null::jsonb;
ELSIF d.status = 'denied' THEN
DELETE FROM oauth_device_codes WHERE id = d.id;
RETURN QUERY SELECT false, 'device_denied'::text, null::jsonb;
ELSIF d.status = 'approved' THEN
DELETE FROM oauth_device_codes WHERE id = d.id;
RETURN QUERY SELECT true, null::text, jsonb_build_object('device_hash', d.device_hash, 'user_code', d.user_code,
'client_id', d.client_id, 'scopes', COALESCE(d.scopes, '[]'::jsonb), 'status', d.status,
'user_id', d.user_id, 'session_token', d.session_token, 'interval', d.poll_interval, 'expires_at', d.expires_at);
ELSE
RETURN QUERY SELECT false, 'device_pending'::text, null::jsonb;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_save_par(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO oauth_par_requests (request_uri, client_id, params, expires_at)
VALUES (p_request->>'request_uri', p_request->>'client_id', p_request->'params',
(p_request->>'expires_at')::timestamptz::timestamp);
RETURN QUERY SELECT true, null::text, null::jsonb;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT false, SQLERRM, null::jsonb;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_consume_par(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
v_row jsonb;
BEGIN
DELETE FROM oauth_par_requests
WHERE request_uri = p_request->>'request_uri' AND expires_at > now()
RETURNING jsonb_build_object('request_uri', request_uri, 'client_id', client_id,
'params', COALESCE(params, '{}'::jsonb), 'expires_at', expires_at)
INTO v_row;
IF v_row IS NULL THEN
RETURN QUERY SELECT false, 'not_found'::text, null::jsonb;
ELSE
RETURN QUERY SELECT true, null::text, v_row;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION resolvespec_oauth_seen_jti(p_request jsonb)
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
LANGUAGE plpgsql AS $$
DECLARE
v_rows int;
BEGIN
DELETE FROM oauth_jti WHERE expires_at < now();
INSERT INTO oauth_jti (jti_key, expires_at)
VALUES (p_request->>'key', (p_request->>'expires_at')::timestamptz::timestamp)
ON CONFLICT (jti_key) DO NOTHING;
GET DIAGNOSTICS v_rows = ROW_COUNT;
RETURN QUERY SELECT true, null::text, jsonb_build_object('seen', v_rows = 0);
END;
$$;
+85
View File
@@ -147,6 +147,7 @@ CREATE TABLE oauth_clients (
client_secret_hash NVARCHAR(MAX),
token_endpoint_auth_method NVARCHAR(30) DEFAULT 'none',
is_active BIT DEFAULT 1,
metadata NVARCHAR(MAX),
created_at DATETIME2 DEFAULT SYSUTCDATETIME()
);
@@ -166,6 +167,7 @@ CREATE TABLE oauth_codes (
refresh_token NVARCHAR(MAX),
scopes NVARCHAR(MAX),
expires_at DATETIME2 NOT NULL,
extra NVARCHAR(MAX),
created_at DATETIME2 DEFAULT SYSUTCDATETIME()
);
@@ -173,6 +175,89 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_codes_expires'
CREATE INDEX idx_oauth_codes_expires ON oauth_codes(expires_at);
-- oauth_consents.scopes / oauth_refresh_tokens.scopes+extra / oauth_device_codes.scopes / oauth_par_requests.params: JSON text
IF OBJECT_ID(N'oauth_consents', N'U') IS NULL
CREATE TABLE oauth_consents (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
client_id NVARCHAR(255) NOT NULL,
scopes NVARCHAR(MAX),
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
expires_at DATETIME2 NOT NULL
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_consents_user_client' AND object_id = OBJECT_ID(N'oauth_consents'))
CREATE INDEX idx_oauth_consents_user_client ON oauth_consents(user_id, client_id);
IF OBJECT_ID(N'oauth_refresh_tokens', N'U') IS NULL
CREATE TABLE oauth_refresh_tokens (
id INT IDENTITY(1,1) PRIMARY KEY,
token_hash NVARCHAR(64) NOT NULL UNIQUE,
family_id NVARCHAR(64) NOT NULL,
client_id NVARCHAR(255) NOT NULL,
user_id INT NOT NULL,
session_token NVARCHAR(255),
scopes NVARCHAR(MAX),
extra NVARCHAR(MAX),
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
expires_at DATETIME2 NOT NULL,
used_at DATETIME2,
revoked_at DATETIME2
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_refresh_family' AND object_id = OBJECT_ID(N'oauth_refresh_tokens'))
CREATE INDEX idx_oauth_refresh_family ON oauth_refresh_tokens(family_id);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_refresh_session' AND object_id = OBJECT_ID(N'oauth_refresh_tokens'))
CREATE INDEX idx_oauth_refresh_session ON oauth_refresh_tokens(session_token);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_refresh_expires' AND object_id = OBJECT_ID(N'oauth_refresh_tokens'))
CREATE INDEX idx_oauth_refresh_expires ON oauth_refresh_tokens(expires_at);
IF OBJECT_ID(N'oauth_device_codes', N'U') IS NULL
CREATE TABLE oauth_device_codes (
id INT IDENTITY(1,1) PRIMARY KEY,
device_hash NVARCHAR(64) NOT NULL UNIQUE,
user_code NVARCHAR(32) NOT NULL UNIQUE,
client_id NVARCHAR(255) NOT NULL,
scopes NVARCHAR(MAX),
status NVARCHAR(16) NOT NULL DEFAULT 'pending',
user_id INT,
session_token NVARCHAR(255),
poll_interval INT NOT NULL DEFAULT 5,
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
expires_at DATETIME2 NOT NULL,
last_polled_at DATETIME2
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_device_expires' AND object_id = OBJECT_ID(N'oauth_device_codes'))
CREATE INDEX idx_oauth_device_expires ON oauth_device_codes(expires_at);
IF OBJECT_ID(N'oauth_par_requests', N'U') IS NULL
CREATE TABLE oauth_par_requests (
id INT IDENTITY(1,1) PRIMARY KEY,
request_uri NVARCHAR(255) NOT NULL UNIQUE,
client_id NVARCHAR(255) NOT NULL,
params NVARCHAR(MAX),
created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
expires_at DATETIME2 NOT NULL
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_par_expires' AND object_id = OBJECT_ID(N'oauth_par_requests'))
CREATE INDEX idx_oauth_par_expires ON oauth_par_requests(expires_at);
IF OBJECT_ID(N'oauth_jti', N'U') IS NULL
CREATE TABLE oauth_jti (
id INT IDENTITY(1,1) PRIMARY KEY,
jti_key NVARCHAR(255) NOT NULL UNIQUE,
expires_at DATETIME2 NOT NULL
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_oauth_jti_expires' AND object_id = OBJECT_ID(N'oauth_jti'))
CREATE INDEX idx_oauth_jti_expires ON oauth_jti(expires_at);
-- key_hash: SHA-256 hex
-- scopes: JSON-encoded array
-- meta: JSON-encoded object
+66
View File
@@ -126,6 +126,7 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
client_secret_hash TEXT,
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
is_active TINYINT(1) DEFAULT 1,
metadata TEXT,
created_at DATETIME NULL
);
@@ -144,11 +145,76 @@ CREATE TABLE IF NOT EXISTS oauth_codes (
refresh_token TEXT,
scopes TEXT,
expires_at DATETIME NOT NULL,
extra TEXT,
created_at DATETIME NULL,
INDEX idx_oauth_codes_expires (expires_at)
);
-- oauth_consents.scopes / oauth_refresh_tokens.scopes+extra / oauth_device_codes.scopes / oauth_par_requests.params: JSON text
CREATE TABLE IF NOT EXISTS oauth_consents (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
client_id VARCHAR(255) NOT NULL,
scopes TEXT,
created_at DATETIME NULL,
expires_at DATETIME NOT NULL,
INDEX idx_oauth_consents_user_client (user_id, client_id)
);
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL UNIQUE,
family_id VARCHAR(64) NOT NULL,
client_id VARCHAR(255) NOT NULL,
user_id INT NOT NULL,
session_token VARCHAR(255),
scopes TEXT,
extra TEXT,
created_at DATETIME NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME,
revoked_at DATETIME,
INDEX idx_oauth_refresh_family (family_id),
INDEX idx_oauth_refresh_session (session_token),
INDEX idx_oauth_refresh_expires (expires_at)
);
CREATE TABLE IF NOT EXISTS oauth_device_codes (
id INT AUTO_INCREMENT PRIMARY KEY,
device_hash VARCHAR(64) NOT NULL UNIQUE,
user_code VARCHAR(32) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
scopes TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
user_id INT,
session_token VARCHAR(255),
poll_interval INT NOT NULL DEFAULT 5,
created_at DATETIME NULL,
expires_at DATETIME NOT NULL,
last_polled_at DATETIME,
INDEX idx_oauth_device_expires (expires_at)
);
CREATE TABLE IF NOT EXISTS oauth_par_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
request_uri VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
params TEXT,
created_at DATETIME NULL,
expires_at DATETIME NOT NULL,
INDEX idx_oauth_par_expires (expires_at)
);
CREATE TABLE IF NOT EXISTS oauth_jti (
id INT AUTO_INCREMENT PRIMARY KEY,
jti_key VARCHAR(255) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
INDEX idx_oauth_jti_expires (expires_at)
);
-- key_hash: SHA-256 hex
-- scopes: JSON-encoded array
-- meta: JSON-encoded object
+73
View File
@@ -137,6 +137,7 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
client_secret_hash TEXT,
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
is_active BOOLEAN DEFAULT true,
metadata TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -155,12 +156,84 @@ CREATE TABLE IF NOT EXISTS oauth_codes (
refresh_token TEXT,
scopes TEXT,
expires_at TIMESTAMP NOT NULL,
extra TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
-- oauth_consents.scopes / oauth_refresh_tokens.scopes+extra / oauth_device_codes.scopes / oauth_par_requests.params: JSON text
CREATE TABLE IF NOT EXISTS oauth_consents (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
client_id VARCHAR(255) NOT NULL,
scopes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_consents_user_client ON oauth_consents(user_id, client_id);
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
id SERIAL PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL UNIQUE,
family_id VARCHAR(64) NOT NULL,
client_id VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL,
session_token VARCHAR(255),
scopes TEXT,
extra TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
revoked_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_family ON oauth_refresh_tokens(family_id);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_session ON oauth_refresh_tokens(session_token);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_expires ON oauth_refresh_tokens(expires_at);
CREATE TABLE IF NOT EXISTS oauth_device_codes (
id SERIAL PRIMARY KEY,
device_hash VARCHAR(64) NOT NULL UNIQUE,
user_code VARCHAR(32) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
scopes TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
user_id INTEGER,
session_token VARCHAR(255),
poll_interval INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
last_polled_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_device_expires ON oauth_device_codes(expires_at);
CREATE TABLE IF NOT EXISTS oauth_par_requests (
id SERIAL PRIMARY KEY,
request_uri VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
params TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_par_expires ON oauth_par_requests(expires_at);
CREATE TABLE IF NOT EXISTS oauth_jti (
id SERIAL PRIMARY KEY,
jti_key VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_jti_expires ON oauth_jti(expires_at);
-- key_hash: SHA-256 hex
-- scopes: JSON-encoded array
-- meta: JSON-encoded object
+73
View File
@@ -130,6 +130,7 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
client_secret_hash TEXT,
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
is_active BOOLEAN DEFAULT 1,
metadata TEXT,
created_at TIMESTAMP
);
@@ -148,12 +149,84 @@ CREATE TABLE IF NOT EXISTS oauth_codes (
refresh_token TEXT,
scopes TEXT,
expires_at TIMESTAMP NOT NULL,
extra TEXT,
created_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
-- oauth_consents.scopes / oauth_refresh_tokens.scopes+extra / oauth_device_codes.scopes / oauth_par_requests.params: JSON text
CREATE TABLE IF NOT EXISTS oauth_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
client_id VARCHAR(255) NOT NULL,
scopes TEXT,
created_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_consents_user_client ON oauth_consents(user_id, client_id);
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash VARCHAR(64) NOT NULL UNIQUE,
family_id VARCHAR(64) NOT NULL,
client_id VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL,
session_token VARCHAR(255),
scopes TEXT,
extra TEXT,
created_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
revoked_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_family ON oauth_refresh_tokens(family_id);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_session ON oauth_refresh_tokens(session_token);
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_expires ON oauth_refresh_tokens(expires_at);
CREATE TABLE IF NOT EXISTS oauth_device_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_hash VARCHAR(64) NOT NULL UNIQUE,
user_code VARCHAR(32) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
scopes TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
user_id INTEGER,
session_token VARCHAR(255),
poll_interval INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
last_polled_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_device_expires ON oauth_device_codes(expires_at);
CREATE TABLE IF NOT EXISTS oauth_par_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_uri VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
params TEXT,
created_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_par_expires ON oauth_par_requests(expires_at);
CREATE TABLE IF NOT EXISTS oauth_jti (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jti_key VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_jti_expires ON oauth_jti(expires_at);
-- key_hash: SHA-256 hex
-- scopes: JSON-encoded array
-- meta: JSON-encoded object
+5
View File
@@ -197,6 +197,11 @@ func Gt(c lookup.Column, v any) Cond {
return func(bl *builder) string { return bl.col(c) + " > " + bl.ph(v) }
}
// Lt is `col < value`.
func Lt(c lookup.Column, v any) Cond {
return func(bl *builder) string { return bl.col(c) + " < " + bl.ph(v) }
}
// IsNull is `col IS NULL`.
func IsNull(c lookup.Column) Cond {
return func(bl *builder) string { return bl.col(c) + " IS NULL" }
+92 -19
View File
@@ -60,6 +60,10 @@ func (o *OAuthClients) RegisterClient(ctx context.Context, client *sectypes.OAut
return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err)
}
meta, err := client.ClientMetadataJSON()
if err != nil {
return nil, fmt.Errorf("failed to marshal client metadata: %w", err)
}
err = o.do(func(q Querier) error {
return o.Insert(lookup.EntityOAuthClients).Set(
Set(lookup.OAuthClientsClientID, client.ClientID),
@@ -70,33 +74,86 @@ func (o *OAuthClients) RegisterClient(ctx context.Context, client *sectypes.OAut
Set(lookup.OAuthClientsClientSecretHash, nullIfEmpty(client.ClientSecretHash)),
Set(lookup.OAuthClientsTokenEndpointAuthMethod, authMethod),
Set(lookup.OAuthClientsIsActive, true),
Set(lookup.OAuthClientsMetadata, nullIfEmpty(meta)),
Set(lookup.OAuthClientsCreatedAt, o.Now()),
).Exec(ctx, q)
})
if err != nil {
return nil, fmt.Errorf("failed to register client: %w", err)
}
return &sectypes.OAuthServerClient{
ClientID: client.ClientID,
RedirectURIs: client.RedirectURIs,
ClientName: client.ClientName,
GrantTypes: grantTypes,
AllowedScopes: allowedScopes,
ClientSecretHash: client.ClientSecretHash,
TokenEndpointAuthMethod: authMethod,
}, nil
res := *client
res.GrantTypes = grantTypes
res.AllowedScopes = allowedScopes
res.TokenEndpointAuthMethod = authMethod
return &res, nil
}
// UpdateClient implements lookup.OAuthClientStore: it rewrites the mutable registration
// fields of an existing client (RFC 7592 management).
func (o *OAuthClients) UpdateClient(ctx context.Context, client *sectypes.OAuthServerClient) error {
redirects, err := o.d.EncodeJSON(client.RedirectURIs)
if err != nil {
return err
}
if redirects == nil {
redirects = "[]"
}
grants, err := o.d.EncodeJSON(client.GrantTypes)
if err != nil {
return err
}
scopes, err := o.d.EncodeJSON(client.AllowedScopes)
if err != nil {
return err
}
meta, err := client.ClientMetadataJSON()
if err != nil {
return err
}
var n int64
err = o.do(func(q Querier) error {
var err error
n, err = o.Update(lookup.EntityOAuthClients).Set(
Set(lookup.OAuthClientsRedirectURIs, redirects),
Set(lookup.OAuthClientsClientName, client.ClientName),
Set(lookup.OAuthClientsGrantTypes, grants),
Set(lookup.OAuthClientsAllowedScopes, scopes),
Set(lookup.OAuthClientsClientSecretHash, nullIfEmpty(client.ClientSecretHash)),
Set(lookup.OAuthClientsTokenEndpointAuthMethod, client.TokenEndpointAuthMethod),
Set(lookup.OAuthClientsMetadata, nullIfEmpty(meta)),
).Where(Eq(lookup.OAuthClientsClientID, client.ClientID), Eq(lookup.OAuthClientsIsActive, true)).Exec(ctx, q)
return err
})
if err != nil {
return fmt.Errorf("failed to update client: %w", err)
}
if n == 0 {
return fmt.Errorf("client not found")
}
return nil
}
// DeleteClient implements lookup.OAuthClientStore: the client is deactivated.
func (o *OAuthClients) DeleteClient(ctx context.Context, clientID string) error {
return o.do(func(q Querier) error {
_, err := o.Update(lookup.EntityOAuthClients).Set(Set(lookup.OAuthClientsIsActive, false)).
Where(Eq(lookup.OAuthClientsClientID, clientID)).Exec(ctx, q)
return err
})
}
// GetClient implements lookup.OAuthClientStore.
func (o *OAuthClients) GetClient(ctx context.Context, clientID string) (*sectypes.OAuthServerClient, error) {
var redirects, grants, scopes any
var name, secret, method sql.NullString
var meta any
err := o.do(func(q Querier) error {
return o.From(lookup.EntityOAuthClients).
Cols(lookup.OAuthClientsRedirectURIs, lookup.OAuthClientsClientName, lookup.OAuthClientsGrantTypes,
lookup.OAuthClientsAllowedScopes, lookup.OAuthClientsClientSecretHash, lookup.OAuthClientsTokenEndpointAuthMethod).
lookup.OAuthClientsAllowedScopes, lookup.OAuthClientsClientSecretHash, lookup.OAuthClientsTokenEndpointAuthMethod,
lookup.OAuthClientsMetadata).
Where(Eq(lookup.OAuthClientsClientID, clientID), Eq(lookup.OAuthClientsIsActive, true)).
QueryRow(ctx, q, &redirects, &name, &grants, &scopes, &secret, &method)
QueryRow(ctx, q, &redirects, &name, &grants, &scopes, &secret, &method, &meta)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -104,12 +161,17 @@ func (o *OAuthClients) GetClient(ctx context.Context, clientID string) (*sectype
}
return nil, fmt.Errorf("failed to get client: %w", err)
}
res := &sectypes.OAuthServerClient{
ClientID: clientID,
ClientName: name.String,
ClientSecretHash: secret.String,
TokenEndpointAuthMethod: method.String,
res := &sectypes.OAuthServerClient{}
switch v := meta.(type) {
case []byte:
_ = res.ApplyClientMetadata(string(v))
case string:
_ = res.ApplyClientMetadata(v)
}
res.ClientID = clientID
res.ClientName = name.String
res.ClientSecretHash = secret.String
res.TokenEndpointAuthMethod = method.String
_ = o.d.DecodeJSON(redirects, &res.RedirectURIs)
_ = o.d.DecodeJSON(grants, &res.GrantTypes)
_ = o.d.DecodeJSON(scopes, &res.AllowedScopes)
@@ -126,6 +188,10 @@ func (o *OAuthClients) SaveCode(ctx context.Context, code *sectypes.OAuthCode) e
if method == "" {
method = "S256"
}
extra, err := code.CodeExtraJSON()
if err != nil {
return fmt.Errorf("failed to marshal code extra: %w", err)
}
return o.do(func(q Querier) error {
return o.Insert(lookup.EntityOAuthCodes).Set(
Set(lookup.OAuthCodesCode, code.Code),
@@ -138,6 +204,7 @@ func (o *OAuthClients) SaveCode(ctx context.Context, code *sectypes.OAuthCode) e
Set(lookup.OAuthCodesRefreshToken, code.RefreshToken),
Set(lookup.OAuthCodesScopes, scopes),
Set(lookup.OAuthCodesExpiresAt, code.ExpiresAt),
Set(lookup.OAuthCodesExtra, nullIfEmpty(extra)),
Set(lookup.OAuthCodesCreatedAt, o.Now()),
).Exec(ctx, q)
})
@@ -148,15 +215,15 @@ func (o *OAuthClients) SaveCode(ctx context.Context, code *sectypes.OAuthCode) e
func (o *OAuthClients) ExchangeCode(ctx context.Context, code string) (*sectypes.OAuthCode, error) {
var res sectypes.OAuthCode
var state, refresh sql.NullString
var scopes any
var scopes, extra any
err := o.tx(ctx, func(q Querier) error {
err := o.From(lookup.EntityOAuthCodes).
Cols(lookup.OAuthCodesClientID, lookup.OAuthCodesRedirectURI, lookup.OAuthCodesClientState,
lookup.OAuthCodesCodeChallenge, lookup.OAuthCodesCodeChallengeMethod, lookup.OAuthCodesSessionToken,
lookup.OAuthCodesRefreshToken, lookup.OAuthCodesScopes).
lookup.OAuthCodesRefreshToken, lookup.OAuthCodesScopes, lookup.OAuthCodesExtra).
Where(Eq(lookup.OAuthCodesCode, code), Gt(lookup.OAuthCodesExpiresAt, o.Now())).
QueryRow(ctx, q, &res.ClientID, &res.RedirectURI, &state, &res.CodeChallenge, &res.CodeChallengeMethod,
&res.SessionToken, &refresh, &scopes)
&res.SessionToken, &refresh, &scopes, &extra)
if err != nil {
return err
}
@@ -179,6 +246,12 @@ func (o *OAuthClients) ExchangeCode(ctx context.Context, code string) (*sectypes
res.ClientState = state.String
res.RefreshToken = refresh.String
_ = o.d.DecodeJSON(scopes, &res.Scopes)
switch v := extra.(type) {
case []byte:
_ = res.ApplyCodeExtra(string(v))
case string:
_ = res.ApplyCodeExtra(v)
}
return &res, nil
}
+475
View File
@@ -0,0 +1,475 @@
package direct
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
// OAuthGrants implements lookup.OAuthGrantStore on tables. Every multi-step operation runs in
// one transaction, and single-use records (refresh rotation, device codes, pushed requests)
// are consumed with a conditional write so concurrent callers cannot both succeed.
type OAuthGrants struct{ *Base }
var _ lookup.OAuthGrantStore = (*OAuthGrants)(nil)
// NewOAuthGrants creates the direct OAuthGrantStore.
func NewOAuthGrants(b *Base) *OAuthGrants { return &OAuthGrants{Base: b} }
// optTime reads a nullable time column scanned into an `any`.
func (o *OAuthGrants) optTime(src any) (time.Time, bool) {
if src == nil {
return time.Time{}, false
}
t, err := o.d.ScanTime(src)
if err != nil || t.IsZero() {
return time.Time{}, false
}
return t, true
}
// jsonArg encodes v for a JSON/TEXT column; an empty value is NULL.
func (o *OAuthGrants) jsonArg(v any) (any, error) { return o.d.EncodeJSON(v) }
// SaveConsent implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SaveConsent(ctx context.Context, c lookup.Consent) error {
scopes, err := o.jsonArg(c.Scopes)
if err != nil {
return err
}
return o.tx(ctx, func(q Querier) error {
if _, err := o.Delete(lookup.EntityOAuthConsents).
Where(Eq(lookup.OAuthConsentsUserID, c.UserID), Eq(lookup.OAuthConsentsClientID, c.ClientID)).Exec(ctx, q); err != nil {
return err
}
return o.Insert(lookup.EntityOAuthConsents).Set(
Set(lookup.OAuthConsentsUserID, c.UserID),
Set(lookup.OAuthConsentsClientID, c.ClientID),
Set(lookup.OAuthConsentsScopes, scopes),
Set(lookup.OAuthConsentsCreatedAt, o.Now()),
Set(lookup.OAuthConsentsExpiresAt, c.ExpiresAt),
).Exec(ctx, q)
})
}
// GetConsent implements lookup.OAuthGrantStore.
func (o *OAuthGrants) GetConsent(ctx context.Context, userID int, clientID string) (*lookup.Consent, error) {
var scopes any
var exp time.Time
err := o.do(func(q Querier) error {
return o.From(lookup.EntityOAuthConsents).
Cols(lookup.OAuthConsentsScopes, lookup.OAuthConsentsExpiresAt).
Where(Eq(lookup.OAuthConsentsUserID, userID), Eq(lookup.OAuthConsentsClientID, clientID),
Gt(lookup.OAuthConsentsExpiresAt, o.Now())).
QueryRow(ctx, q, &scopes, o.timeDest(&exp))
})
if errors.Is(err, sql.ErrNoRows) {
return nil, lookup.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get consent: %w", err)
}
c := &lookup.Consent{UserID: userID, ClientID: clientID, ExpiresAt: exp}
_ = o.d.DecodeJSON(scopes, &c.Scopes)
return c, nil
}
// RevokeConsent implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RevokeConsent(ctx context.Context, userID int, clientID string) error {
return o.do(func(q Querier) error {
_, err := o.Delete(lookup.EntityOAuthConsents).
Where(Eq(lookup.OAuthConsentsUserID, userID), Eq(lookup.OAuthConsentsClientID, clientID)).Exec(ctx, q)
return err
})
}
func (o *OAuthGrants) insertRefresh(ctx context.Context, q Querier, t lookup.RefreshToken) error {
scopes, err := o.jsonArg(t.Scopes)
if err != nil {
return err
}
extra, err := o.jsonArg(t.Extra)
if err != nil {
return err
}
return o.Insert(lookup.EntityOAuthRefreshTokens).Set(
Set(lookup.OAuthRefreshTokenHash, t.TokenHash),
Set(lookup.OAuthRefreshFamilyID, t.FamilyID),
Set(lookup.OAuthRefreshClientID, t.ClientID),
Set(lookup.OAuthRefreshUserID, t.UserID),
Set(lookup.OAuthRefreshSessionToken, t.SessionToken),
Set(lookup.OAuthRefreshScopes, scopes),
Set(lookup.OAuthRefreshExtra, extra),
Set(lookup.OAuthRefreshCreatedAt, o.Now()),
Set(lookup.OAuthRefreshExpiresAt, t.ExpiresAt),
).Exec(ctx, q)
}
// SaveRefresh implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SaveRefresh(ctx context.Context, t lookup.RefreshToken) error {
return o.do(func(q Querier) error { return o.insertRefresh(ctx, q, t) })
}
type refreshRow struct {
lookup.RefreshToken
used, revoked bool
expired bool
}
func (o *OAuthGrants) loadRefresh(ctx context.Context, q Querier, hash string) (*refreshRow, error) {
var scopes, extra, usedAt, revokedAt any
var exp time.Time
var session sql.NullString
r := &refreshRow{}
r.TokenHash = hash
err := o.From(lookup.EntityOAuthRefreshTokens).
Cols(lookup.OAuthRefreshFamilyID, lookup.OAuthRefreshClientID, lookup.OAuthRefreshUserID,
lookup.OAuthRefreshSessionToken, lookup.OAuthRefreshScopes, lookup.OAuthRefreshExtra,
lookup.OAuthRefreshExpiresAt, lookup.OAuthRefreshUsedAt, lookup.OAuthRefreshRevokedAt).
Where(Eq(lookup.OAuthRefreshTokenHash, hash)).
QueryRow(ctx, q, &r.FamilyID, &r.ClientID, &r.UserID, &session, &scopes, &extra, o.timeDest(&exp), &usedAt, &revokedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, lookup.ErrRefreshInvalid
}
return nil, err
}
r.SessionToken = session.String
r.ExpiresAt = exp
_ = o.d.DecodeJSON(scopes, &r.Scopes)
_ = o.d.DecodeJSON(extra, &r.Extra)
_, r.used = o.optTime(usedAt)
_, r.revoked = o.optTime(revokedAt)
r.expired = !exp.After(o.Now())
return r, nil
}
func (o *OAuthGrants) revokeFamilyTx(ctx context.Context, q Querier, family string) error {
_, err := o.Update(lookup.EntityOAuthRefreshTokens).
Set(Set(lookup.OAuthRefreshRevokedAt, o.Now())).
Where(Eq(lookup.OAuthRefreshFamilyID, family), IsNull(lookup.OAuthRefreshRevokedAt)).Exec(ctx, q)
return err
}
// RotateRefresh implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RotateRefresh(ctx context.Context, oldHash string, next lookup.RefreshToken) (*lookup.RefreshToken, error) {
var old *refreshRow
var reused bool
err := o.tx(ctx, func(q Querier) error {
r, err := o.loadRefresh(ctx, q, oldHash)
if err != nil {
return err
}
if r.revoked || r.expired {
return lookup.ErrRefreshInvalid
}
old = r
if r.used {
// A rotated token came back: the family is compromised. The revoke must commit, so
// the reuse is reported after the transaction instead of rolling it back.
reused = true
return o.revokeFamilyTx(ctx, q, r.FamilyID)
}
n, err := o.Update(lookup.EntityOAuthRefreshTokens).
Set(Set(lookup.OAuthRefreshUsedAt, o.Now())).
Where(Eq(lookup.OAuthRefreshTokenHash, oldHash), IsNull(lookup.OAuthRefreshUsedAt)).Exec(ctx, q)
if err != nil {
return err
}
if n == 0 { // lost a race with a concurrent rotation of the same token
reused = true
return o.revokeFamilyTx(ctx, q, r.FamilyID)
}
next.FamilyID = r.FamilyID
next.ClientID = r.ClientID
next.UserID = r.UserID
if next.SessionToken == "" {
next.SessionToken = r.SessionToken
}
return o.insertRefresh(ctx, q, next)
})
if err != nil {
if errors.Is(err, lookup.ErrRefreshInvalid) {
return nil, err
}
return nil, fmt.Errorf("failed to rotate refresh token: %w", err)
}
tok := old.RefreshToken
if reused {
return &tok, lookup.ErrRefreshReused
}
return &tok, nil
}
// PeekRefresh implements lookup.OAuthGrantStore.
func (o *OAuthGrants) PeekRefresh(ctx context.Context, hash string) (*lookup.RefreshToken, error) {
var r *refreshRow
err := o.do(func(q Querier) error {
var err error
r, err = o.loadRefresh(ctx, q, hash)
return err
})
if err != nil {
if errors.Is(err, lookup.ErrRefreshInvalid) {
return nil, err
}
return nil, fmt.Errorf("failed to read refresh token: %w", err)
}
if r.revoked || r.expired { // a rotated token is still returned so its reuse is detected by RotateRefresh
return nil, lookup.ErrRefreshInvalid
}
t := r.RefreshToken
return &t, nil
}
// RevokeRefreshFamily implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RevokeRefreshFamily(ctx context.Context, familyID string) error {
return o.do(func(q Querier) error { return o.revokeFamilyTx(ctx, q, familyID) })
}
// RevokeRefreshBySession implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RevokeRefreshBySession(ctx context.Context, sessionToken string) error {
return o.do(func(q Querier) error {
_, err := o.Update(lookup.EntityOAuthRefreshTokens).
Set(Set(lookup.OAuthRefreshRevokedAt, o.Now())).
Where(Eq(lookup.OAuthRefreshSessionToken, sessionToken), IsNull(lookup.OAuthRefreshRevokedAt)).Exec(ctx, q)
return err
})
}
// CreateDevice implements lookup.OAuthGrantStore.
func (o *OAuthGrants) CreateDevice(ctx context.Context, d lookup.DeviceCode) error {
scopes, err := o.jsonArg(d.Scopes)
if err != nil {
return err
}
status := d.Status
if status == "" {
status = lookup.DevicePending
}
return o.do(func(q Querier) error {
return o.Insert(lookup.EntityOAuthDeviceCodes).Set(
Set(lookup.OAuthDeviceHash, d.DeviceHash),
Set(lookup.OAuthDeviceUserCode, strings.ToUpper(d.UserCode)),
Set(lookup.OAuthDeviceClientID, d.ClientID),
Set(lookup.OAuthDeviceScopes, scopes),
Set(lookup.OAuthDeviceStatus, string(status)),
Set(lookup.OAuthDeviceInterval, d.Interval),
Set(lookup.OAuthDeviceCreatedAt, o.Now()),
Set(lookup.OAuthDeviceExpiresAt, d.ExpiresAt),
).Exec(ctx, q)
})
}
// DeviceByUserCode implements lookup.OAuthGrantStore.
func (o *OAuthGrants) DeviceByUserCode(ctx context.Context, userCode string) (*lookup.DeviceCode, error) {
var d lookup.DeviceCode
var scopes any
var status string
var exp time.Time
userCode = strings.ToUpper(userCode)
err := o.do(func(q Querier) error {
return o.From(lookup.EntityOAuthDeviceCodes).
Cols(lookup.OAuthDeviceHash, lookup.OAuthDeviceClientID, lookup.OAuthDeviceScopes, lookup.OAuthDeviceStatus,
lookup.OAuthDeviceInterval, lookup.OAuthDeviceExpiresAt).
Where(Eq(lookup.OAuthDeviceUserCode, userCode), Eq(lookup.OAuthDeviceStatus, string(lookup.DevicePending)),
Gt(lookup.OAuthDeviceExpiresAt, o.Now())).
QueryRow(ctx, q, &d.DeviceHash, &d.ClientID, &scopes, &status, &d.Interval, o.timeDest(&exp))
})
if errors.Is(err, sql.ErrNoRows) {
return nil, lookup.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to read device code: %w", err)
}
d.UserCode = userCode
d.Status = lookup.DeviceStatus(status)
d.ExpiresAt = exp
_ = o.d.DecodeJSON(scopes, &d.Scopes)
return &d, nil
}
// DeviceDecide implements lookup.OAuthGrantStore.
func (o *OAuthGrants) DeviceDecide(ctx context.Context, userCode string, approve bool, userID int, sessionToken string) error {
status := lookup.DeviceDenied
sets := []Assignment{}
if approve {
status = lookup.DeviceApproved
sets = append(sets, Set(lookup.OAuthDeviceUserID, userID), Set(lookup.OAuthDeviceSessionToken, sessionToken))
}
sets = append(sets, Set(lookup.OAuthDeviceStatus, string(status)))
var n int64
err := o.do(func(q Querier) error {
var err error
n, err = o.Update(lookup.EntityOAuthDeviceCodes).Set(sets...).
Where(Eq(lookup.OAuthDeviceUserCode, strings.ToUpper(userCode)),
Eq(lookup.OAuthDeviceStatus, string(lookup.DevicePending)),
Gt(lookup.OAuthDeviceExpiresAt, o.Now())).Exec(ctx, q)
return err
})
if err != nil {
return fmt.Errorf("failed to decide device code: %w", err)
}
if n == 0 {
return lookup.ErrNotFound
}
return nil
}
// DevicePoll implements lookup.OAuthGrantStore.
func (o *OAuthGrants) DevicePoll(ctx context.Context, deviceHash string) (*lookup.DeviceCode, error) {
var out *lookup.DeviceCode
var outErr error
err := o.tx(ctx, func(q Querier) error {
var d lookup.DeviceCode
var scopes, polled any
var status string
var userID sql.NullInt64
var session sql.NullString
var exp time.Time
err := o.From(lookup.EntityOAuthDeviceCodes).
Cols(lookup.OAuthDeviceUserCode, lookup.OAuthDeviceClientID, lookup.OAuthDeviceScopes, lookup.OAuthDeviceStatus,
lookup.OAuthDeviceUserID, lookup.OAuthDeviceSessionToken, lookup.OAuthDeviceInterval,
lookup.OAuthDeviceExpiresAt, lookup.OAuthDeviceLastPolledAt).
Where(Eq(lookup.OAuthDeviceHash, deviceHash)).
QueryRow(ctx, q, &d.UserCode, &d.ClientID, &scopes, &status, &userID, &session, &d.Interval, o.timeDest(&exp), &polled)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
outErr = lookup.ErrDeviceExpired
return nil
}
return err
}
now := o.Now()
del := func() error {
_, err := o.Delete(lookup.EntityOAuthDeviceCodes).Where(Eq(lookup.OAuthDeviceHash, deviceHash)).Exec(ctx, q)
return err
}
if !exp.After(now) {
outErr = lookup.ErrDeviceExpired
return del()
}
if last, ok := o.optTime(polled); ok && now.Sub(last) < time.Duration(d.Interval)*time.Second {
outErr = lookup.ErrDeviceSlowDown
}
if _, err := o.Update(lookup.EntityOAuthDeviceCodes).Set(Set(lookup.OAuthDeviceLastPolledAt, now)).
Where(Eq(lookup.OAuthDeviceHash, deviceHash)).Exec(ctx, q); err != nil {
return err
}
if outErr != nil {
return nil
}
switch lookup.DeviceStatus(status) {
case lookup.DeviceDenied:
outErr = lookup.ErrDeviceDenied
return del()
case lookup.DeviceApproved:
d.DeviceHash = deviceHash
d.Status = lookup.DeviceApproved
d.UserID = int(userID.Int64)
d.SessionToken = session.String
d.ExpiresAt = exp
_ = o.d.DecodeJSON(scopes, &d.Scopes)
out = &d
return del()
}
outErr = lookup.ErrDevicePending
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to poll device code: %w", err)
}
return out, outErr
}
// SavePushedRequest implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SavePushedRequest(ctx context.Context, r lookup.PushedRequest) error {
params, err := o.jsonArg(r.Params)
if err != nil {
return err
}
return o.do(func(q Querier) error {
return o.Insert(lookup.EntityOAuthPARRequests).Set(
Set(lookup.OAuthPARRequestURI, r.RequestURI),
Set(lookup.OAuthPARClientID, r.ClientID),
Set(lookup.OAuthPARParams, params),
Set(lookup.OAuthPARCreatedAt, o.Now()),
Set(lookup.OAuthPARExpiresAt, r.ExpiresAt),
).Exec(ctx, q)
})
}
// ConsumePushedRequest implements lookup.OAuthGrantStore.
func (o *OAuthGrants) ConsumePushedRequest(ctx context.Context, requestURI string) (*lookup.PushedRequest, error) {
r := &lookup.PushedRequest{RequestURI: requestURI}
var params any
var exp time.Time
err := o.tx(ctx, func(q Querier) error {
if err := o.From(lookup.EntityOAuthPARRequests).
Cols(lookup.OAuthPARClientID, lookup.OAuthPARParams, lookup.OAuthPARExpiresAt).
Where(Eq(lookup.OAuthPARRequestURI, requestURI), Gt(lookup.OAuthPARExpiresAt, o.Now())).
QueryRow(ctx, q, &r.ClientID, &params, o.timeDest(&exp)); err != nil {
return err
}
n, err := o.Delete(lookup.EntityOAuthPARRequests).Where(Eq(lookup.OAuthPARRequestURI, requestURI)).Exec(ctx, q)
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
return nil
})
if errors.Is(err, sql.ErrNoRows) {
return nil, lookup.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to consume pushed request: %w", err)
}
r.ExpiresAt = exp
_ = o.d.DecodeJSON(params, &r.Params)
return r, nil
}
// SeenJTI implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SeenJTI(ctx context.Context, key string, expires time.Time) (bool, error) {
seen := false
err := o.tx(ctx, func(q Querier) error {
if _, err := o.Delete(lookup.EntityOAuthJTI).Where(Lt(lookup.OAuthJTIExpiresAt, o.Now())).Exec(ctx, q); err != nil {
return err
}
exists, err := o.From(lookup.EntityOAuthJTI).Cols(lookup.OAuthJTIKey).Where(Eq(lookup.OAuthJTIKey, key)).Exists(ctx, q)
if err != nil {
return err
}
if exists {
seen = true
return nil
}
return o.Insert(lookup.EntityOAuthJTI).Set(
Set(lookup.OAuthJTIKey, key), Set(lookup.OAuthJTIExpiresAt, expires)).Exec(ctx, q)
})
if err != nil {
// A concurrent insert of the same key violates the unique index: that is a replay.
if ok, qerr := o.keyExists(ctx, key); qerr == nil && ok {
return true, nil
}
return false, fmt.Errorf("failed to record jti: %w", err)
}
return seen, nil
}
func (o *OAuthGrants) keyExists(ctx context.Context, key string) (bool, error) {
var ok bool
err := o.do(func(q Querier) error {
var err error
ok, err = o.From(lookup.EntityOAuthJTI).Cols(lookup.OAuthJTIKey).Where(Eq(lookup.OAuthJTIKey, key)).Exists(ctx, q)
return err
})
return ok, err
}
+117
View File
@@ -0,0 +1,117 @@
package lookup
import (
"context"
"errors"
"time"
)
// Errors returned by OAuthGrantStore. Callers compare with errors.Is.
var (
// ErrRefreshInvalid: the refresh token is unknown, expired or revoked.
ErrRefreshInvalid = errors.New("invalid refresh token")
// ErrRefreshReused: a refresh token that was already rotated was presented again. The
// store has revoked the whole token family.
ErrRefreshReused = errors.New("refresh token reuse detected")
// ErrDevicePending, ErrDeviceSlowDown, ErrDeviceDenied and ErrDeviceExpired are the RFC 8628
// polling outcomes other than success.
ErrDevicePending = errors.New("authorization pending")
ErrDeviceSlowDown = errors.New("slow down")
ErrDeviceDenied = errors.New("access denied")
ErrDeviceExpired = errors.New("device code expired")
// ErrNotFound: the requested record does not exist or has expired.
ErrNotFound = errors.New("not found")
)
// Consent records that a user allowed a client to act with Scopes.
type Consent struct {
UserID int `json:"user_id"`
ClientID string `json:"client_id"`
Scopes []string `json:"scopes"`
ExpiresAt time.Time `json:"expires_at"`
}
// RefreshToken is a server-managed refresh token. Only the SHA-256 hash of the raw token
// is stored.
type RefreshToken struct {
TokenHash string `json:"token_hash"`
FamilyID string `json:"family_id"`
ClientID string `json:"client_id"`
UserID int `json:"user_id"`
SessionToken string `json:"session_token"`
Scopes []string `json:"scopes,omitempty"`
Extra map[string]any `json:"extra,omitempty"` // nonce, auth_time, acr, sid, dpop_jkt, resource
ExpiresAt time.Time `json:"expires_at"`
}
// DeviceStatus is the state of an RFC 8628 device authorization.
type DeviceStatus string
const (
DevicePending DeviceStatus = "pending"
DeviceApproved DeviceStatus = "approved"
DeviceDenied DeviceStatus = "denied"
)
// DeviceCode is a pending RFC 8628 device authorization. DeviceHash is the SHA-256 hash of the
// device_code returned to the device; UserCode is stored as typed by the user (normalised).
type DeviceCode struct {
DeviceHash string `json:"device_hash"`
UserCode string `json:"user_code"`
ClientID string `json:"client_id"`
Scopes []string `json:"scopes,omitempty"`
Status DeviceStatus `json:"status"`
UserID int `json:"user_id,omitempty"`
SessionToken string `json:"session_token,omitempty"`
Interval int `json:"interval"` // minimum seconds between polls
ExpiresAt time.Time `json:"expires_at"`
}
// PushedRequest is an RFC 9126 pushed authorization request.
type PushedRequest struct {
RequestURI string `json:"request_uri"`
ClientID string `json:"client_id"`
Params map[string]string `json:"params"`
ExpiresAt time.Time `json:"expires_at"`
}
// OAuthGrantStore persists the OAuth2 authorization server state that is not a client, a code
// or a session: consents, refresh tokens, device codes, pushed requests and the replay cache.
type OAuthGrantStore interface {
// SaveConsent replaces the consent of (UserID, ClientID).
SaveConsent(ctx context.Context, c Consent) error
// GetConsent returns the unexpired consent or ErrNotFound.
GetConsent(ctx context.Context, userID int, clientID string) (*Consent, error)
RevokeConsent(ctx context.Context, userID int, clientID string) error
SaveRefresh(ctx context.Context, t RefreshToken) error
// RotateRefresh atomically consumes the token with hash oldHash and stores next in the same
// family. It returns the consumed token. An unknown, expired or revoked token is
// ErrRefreshInvalid. A token that was already consumed revokes its family and returns the
// token together with ErrRefreshReused so the caller can end the session.
RotateRefresh(ctx context.Context, oldHash string, next RefreshToken) (*RefreshToken, error)
// PeekRefresh returns the token without consuming it. Unknown, expired and revoked tokens are
// ErrRefreshInvalid; an already rotated token is returned so RotateRefresh can report its reuse.
PeekRefresh(ctx context.Context, hash string) (*RefreshToken, error)
RevokeRefreshFamily(ctx context.Context, familyID string) error
// RevokeRefreshBySession revokes every refresh token bound to a session token.
RevokeRefreshBySession(ctx context.Context, sessionToken string) error
CreateDevice(ctx context.Context, d DeviceCode) error
// DeviceByUserCode returns the unexpired pending device authorization or ErrNotFound.
DeviceByUserCode(ctx context.Context, userCode string) (*DeviceCode, error)
// DeviceDecide approves or denies the device authorization of userCode.
DeviceDecide(ctx context.Context, userCode string, approve bool, userID int, sessionToken string) error
// DevicePoll implements the token endpoint side: it enforces the poll interval and returns
// one of ErrDevicePending, ErrDeviceSlowDown, ErrDeviceDenied, ErrDeviceExpired or, once
// approved, the record (consumed: it cannot be polled again).
DevicePoll(ctx context.Context, deviceHash string) (*DeviceCode, error)
SavePushedRequest(ctx context.Context, r PushedRequest) error
// ConsumePushedRequest returns and deletes the request or ErrNotFound.
ConsumePushedRequest(ctx context.Context, requestURI string) (*PushedRequest, error)
// SeenJTI records key until expires and reports whether it was already recorded. It is the
// replay cache for DPoP proofs and client assertions.
SeenJTI(ctx context.Context, key string, expires time.Time) (bool, error)
}
+5
View File
@@ -62,6 +62,10 @@ type OAuthClientStore interface {
ExchangeCode(ctx context.Context, code string) (*sectypes.OAuthCode, error)
Introspect(ctx context.Context, token string) (*sectypes.OAuthTokenInfo, error)
Revoke(ctx context.Context, token string) error
// UpdateClient rewrites the registration fields of an existing client (RFC 7592).
UpdateClient(ctx context.Context, client *sectypes.OAuthServerClient) error
// DeleteClient deactivates a client.
DeleteClient(ctx context.Context, clientID string) error
}
// OAuthSession is the session row written after an OAuth2 client login.
@@ -147,6 +151,7 @@ type Provider struct {
Keys KeyStore
OAuthClient OAuthClientStore
OAuthUser OAuthUserStore
OAuthGrant OAuthGrantStore
Passkey PasskeyStore
TOTP TOTPStore
Policy PolicyStore
+35
View File
@@ -61,6 +61,8 @@ const (
OpOAuthExchangeCode Op = "oauth_exchange_code"
OpOAuthIntrospect Op = "oauth_introspect"
OpOAuthRevoke Op = "oauth_revoke"
OpOAuthUpdateClient Op = "oauth_update_client"
OpOAuthDeleteClient Op = "oauth_delete_client"
OpOAuthGetOrCreateUser Op = "oauth_get_or_create_user"
OpOAuthCreateSession Op = "oauth_create_session"
@@ -68,6 +70,22 @@ const (
OpOAuthUpdateRefreshToken Op = "oauth_update_refresh_token" //nolint:gosec // operation name, not a credential
OpOAuthGetUser Op = "oauth_get_user"
OpOAuthSaveConsent Op = "oauth_save_consent"
OpOAuthGetConsent Op = "oauth_get_consent"
OpOAuthRevokeConsent Op = "oauth_revoke_consent"
OpOAuthSaveRefresh Op = "oauth_save_refresh" //nolint:gosec // operation name, not a credential
OpOAuthRotateRefresh Op = "oauth_rotate_refresh" //nolint:gosec // operation name, not a credential
OpOAuthPeekRefresh Op = "oauth_peek_refresh" //nolint:gosec // operation name, not a credential
OpOAuthRevokeRefreshFamily Op = "oauth_revoke_refresh_family" //nolint:gosec // operation name, not a credential
OpOAuthRevokeRefreshByUser Op = "oauth_revoke_refresh_session" //nolint:gosec // operation name, not a credential
OpOAuthCreateDevice Op = "oauth_create_device"
OpOAuthDeviceByUserCode Op = "oauth_device_by_user_code"
OpOAuthDeviceDecide Op = "oauth_device_decide"
OpOAuthDevicePoll Op = "oauth_device_poll"
OpOAuthSavePAR Op = "oauth_save_par"
OpOAuthConsumePAR Op = "oauth_consume_par"
OpOAuthSeenJTI Op = "oauth_seen_jti"
OpPasskeyStore Op = "passkey_store"
OpPasskeyGet Op = "passkey_get"
OpPasskeyUpdateCounter Op = "passkey_update_counter"
@@ -144,11 +162,28 @@ func AllOps() []Op {
OpOAuthExchangeCode,
OpOAuthIntrospect,
OpOAuthRevoke,
OpOAuthUpdateClient,
OpOAuthDeleteClient,
OpOAuthGetOrCreateUser,
OpOAuthCreateSession,
OpOAuthGetRefreshToken,
OpOAuthUpdateRefreshToken,
OpOAuthGetUser,
OpOAuthSaveConsent,
OpOAuthGetConsent,
OpOAuthRevokeConsent,
OpOAuthSaveRefresh,
OpOAuthRotateRefresh,
OpOAuthPeekRefresh,
OpOAuthRevokeRefreshFamily,
OpOAuthRevokeRefreshByUser,
OpOAuthCreateDevice,
OpOAuthDeviceByUserCode,
OpOAuthDeviceDecide,
OpOAuthDevicePoll,
OpOAuthSavePAR,
OpOAuthConsumePAR,
OpOAuthSeenJTI,
OpPasskeyStore,
OpPasskeyGet,
OpPasskeyUpdateCounter,
+35
View File
@@ -313,3 +313,38 @@ func (o *OAuthClients) Revoke(ctx context.Context, token string) error {
}
return nil
}
// UpdateClient implements lookup.OAuthClientStore.
func (o *OAuthClients) UpdateClient(ctx context.Context, client *sectypes.OAuthServerClient) error {
input, err := json.Marshal(client)
if err != nil {
return fmt.Errorf("failed to marshal client: %w", err)
}
var success bool
var errMsg sql.NullString
err = o.run.Run(func(db *sql.DB) error {
return db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error
FROM %s($1::jsonb)
`, o.procs.OAuthUpdateClient), input).Scan(&success, &errMsg)
})
if err != nil {
return fmt.Errorf("failed to update client: %w", err)
}
if !success {
return failure(errMsg, "failed to update client")
}
return nil
}
// DeleteClient implements lookup.OAuthClientStore.
func (o *OAuthClients) DeleteClient(ctx context.Context, clientID string) error {
ok, errMsg, err := o.callNoData(ctx, o.procs.OAuthDeleteClient, clientID)
if err != nil {
return fmt.Errorf("failed to delete client: %w", err)
}
if !ok {
return failure(errMsg, "failed to delete client")
}
return nil
}
@@ -0,0 +1,217 @@
package procedure
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
// OAuthGrants implements lookup.OAuthGrantStore with the resolvespec_oauth_* grant procedures.
// Every procedure takes one jsonb request and returns (p_success, p_error, p_data). A failure
// that maps to a lookup sentinel carries a stable code in p_error (see the grantErrors table).
type OAuthGrants struct {
run Runner
procs lookup.ProcNames
}
var _ lookup.OAuthGrantStore = (*OAuthGrants)(nil)
// NewOAuthGrants creates the procedure-backed OAuthGrantStore.
func NewOAuthGrants(run Runner, procs lookup.ProcNames) *OAuthGrants {
return &OAuthGrants{run: run, procs: procs}
}
// grantErrors maps the codes a grant procedure puts in p_error to the lookup sentinels.
var grantErrors = map[string]error{
"not_found": lookup.ErrNotFound,
"refresh_invalid": lookup.ErrRefreshInvalid,
"refresh_reused": lookup.ErrRefreshReused,
"device_pending": lookup.ErrDevicePending,
"device_slowdown": lookup.ErrDeviceSlowDown,
"device_denied": lookup.ErrDeviceDenied,
"device_expired": lookup.ErrDeviceExpired,
}
// call runs proc with the JSON-encoded request. The returned data is the p_data of the
// procedure, also when it reports a failure (rotate returns the reused token that way).
func (o *OAuthGrants) call(ctx context.Context, proc string, req any) (data []byte, err error) {
input, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
var success bool
var errMsg sql.NullString
err = o.run.Run(func(db *sql.DB) error {
return db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error, p_data::text
FROM %s($1::jsonb)
`, proc), input).Scan(&success, &errMsg, &data)
})
if err != nil {
return nil, fmt.Errorf("%s: %w", proc, err)
}
if success {
return data, nil
}
if e, ok := grantErrors[errMsg.String]; ok {
return data, e
}
return data, failure(errMsg, proc+" failed")
}
// SaveConsent implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SaveConsent(ctx context.Context, c lookup.Consent) error {
_, err := o.call(ctx, o.procs.OAuthSaveConsent, c)
return err
}
// GetConsent implements lookup.OAuthGrantStore.
func (o *OAuthGrants) GetConsent(ctx context.Context, userID int, clientID string) (*lookup.Consent, error) {
data, err := o.call(ctx, o.procs.OAuthGetConsent, map[string]any{"user_id": userID, "client_id": clientID})
if err != nil {
return nil, err
}
var c lookup.Consent
if err := json.Unmarshal(normalizeTimes(data), &c); err != nil {
return nil, fmt.Errorf("failed to parse consent: %w", err)
}
return &c, nil
}
// RevokeConsent implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RevokeConsent(ctx context.Context, userID int, clientID string) error {
_, err := o.call(ctx, o.procs.OAuthRevokeConsent, map[string]any{"user_id": userID, "client_id": clientID})
return err
}
// SaveRefresh implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SaveRefresh(ctx context.Context, t lookup.RefreshToken) error {
_, err := o.call(ctx, o.procs.OAuthSaveRefresh, t)
return err
}
func parseRefresh(data []byte) (*lookup.RefreshToken, error) {
if len(data) == 0 {
return nil, nil
}
var t lookup.RefreshToken
if err := json.Unmarshal(normalizeTimes(data), &t); err != nil {
return nil, fmt.Errorf("failed to parse refresh token: %w", err)
}
return &t, nil
}
// RotateRefresh implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RotateRefresh(ctx context.Context, oldHash string, next lookup.RefreshToken) (*lookup.RefreshToken, error) {
data, err := o.call(ctx, o.procs.OAuthRotateRefresh, map[string]any{"old_hash": oldHash, "next": next})
if err != nil && err != lookup.ErrRefreshReused { //nolint:errorlint // sentinel returned unwrapped by call
return nil, err
}
t, perr := parseRefresh(data)
if perr != nil {
return nil, perr
}
return t, err
}
// PeekRefresh implements lookup.OAuthGrantStore.
func (o *OAuthGrants) PeekRefresh(ctx context.Context, hash string) (*lookup.RefreshToken, error) {
data, err := o.call(ctx, o.procs.OAuthPeekRefresh, map[string]any{"token_hash": hash})
if err != nil {
return nil, err
}
return parseRefresh(data)
}
// RevokeRefreshFamily implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RevokeRefreshFamily(ctx context.Context, familyID string) error {
_, err := o.call(ctx, o.procs.OAuthRevokeRefreshFamily, map[string]any{"family_id": familyID})
return err
}
// RevokeRefreshBySession implements lookup.OAuthGrantStore.
func (o *OAuthGrants) RevokeRefreshBySession(ctx context.Context, sessionToken string) error {
_, err := o.call(ctx, o.procs.OAuthRevokeRefreshByUser, map[string]any{"session_token": sessionToken})
return err
}
// CreateDevice implements lookup.OAuthGrantStore.
func (o *OAuthGrants) CreateDevice(ctx context.Context, d lookup.DeviceCode) error {
if d.Status == "" {
d.Status = lookup.DevicePending
}
_, err := o.call(ctx, o.procs.OAuthCreateDevice, d)
return err
}
func parseDevice(data []byte) (*lookup.DeviceCode, error) {
var d lookup.DeviceCode
if err := json.Unmarshal(normalizeTimes(data), &d); err != nil {
return nil, fmt.Errorf("failed to parse device code: %w", err)
}
return &d, nil
}
// DeviceByUserCode implements lookup.OAuthGrantStore.
func (o *OAuthGrants) DeviceByUserCode(ctx context.Context, userCode string) (*lookup.DeviceCode, error) {
data, err := o.call(ctx, o.procs.OAuthDeviceByUserCode, map[string]any{"user_code": userCode})
if err != nil {
return nil, err
}
return parseDevice(data)
}
// DeviceDecide implements lookup.OAuthGrantStore.
func (o *OAuthGrants) DeviceDecide(ctx context.Context, userCode string, approve bool, userID int, sessionToken string) error {
_, err := o.call(ctx, o.procs.OAuthDeviceDecide, map[string]any{
"user_code": userCode, "approve": approve, "user_id": userID, "session_token": sessionToken,
})
return err
}
// DevicePoll implements lookup.OAuthGrantStore.
func (o *OAuthGrants) DevicePoll(ctx context.Context, deviceHash string) (*lookup.DeviceCode, error) {
data, err := o.call(ctx, o.procs.OAuthDevicePoll, map[string]any{"device_hash": deviceHash})
if err != nil {
return nil, err
}
return parseDevice(data)
}
// SavePushedRequest implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SavePushedRequest(ctx context.Context, r lookup.PushedRequest) error {
_, err := o.call(ctx, o.procs.OAuthSavePAR, r)
return err
}
// ConsumePushedRequest implements lookup.OAuthGrantStore.
func (o *OAuthGrants) ConsumePushedRequest(ctx context.Context, requestURI string) (*lookup.PushedRequest, error) {
data, err := o.call(ctx, o.procs.OAuthConsumePAR, map[string]any{"request_uri": requestURI})
if err != nil {
return nil, err
}
var r lookup.PushedRequest
if err := json.Unmarshal(normalizeTimes(data), &r); err != nil {
return nil, fmt.Errorf("failed to parse pushed request: %w", err)
}
return &r, nil
}
// SeenJTI implements lookup.OAuthGrantStore.
func (o *OAuthGrants) SeenJTI(ctx context.Context, key string, expires time.Time) (bool, error) {
data, err := o.call(ctx, o.procs.OAuthSeenJTI, map[string]any{"key": key, "expires_at": expires})
if err != nil {
return false, err
}
var out struct {
Seen bool `json:"seen"`
}
if err := json.Unmarshal(data, &out); err != nil {
return false, fmt.Errorf("failed to parse jti result: %w", err)
}
return out.Seen, nil
}
+37
View File
@@ -63,6 +63,26 @@ type ProcNames struct {
OAuthExchangeCode string // default: "resolvespec_oauth_exchange_code"
OAuthIntrospect string // default: "resolvespec_oauth_introspect"
OAuthRevoke string // default: "resolvespec_oauth_revoke"
OAuthUpdateClient string // default: "resolvespec_oauth_update_client"
OAuthDeleteClient string // default: "resolvespec_oauth_delete_client"
// OAuth2 server grant procedures (consents, refresh tokens, device codes, PAR, replay cache).
// Each takes a jsonb request and returns (p_success, p_error, p_data).
OAuthSaveConsent string // default: "resolvespec_oauth_save_consent"
OAuthGetConsent string // default: "resolvespec_oauth_get_consent"
OAuthRevokeConsent string // default: "resolvespec_oauth_revoke_consent"
OAuthSaveRefresh string // default: "resolvespec_oauth_save_refresh"
OAuthRotateRefresh string // default: "resolvespec_oauth_rotate_refresh"
OAuthPeekRefresh string // default: "resolvespec_oauth_peek_refresh"
OAuthRevokeRefreshFamily string // default: "resolvespec_oauth_revoke_refresh_family"
OAuthRevokeRefreshByUser string // default: "resolvespec_oauth_revoke_refresh_session"
OAuthCreateDevice string // default: "resolvespec_oauth_create_device"
OAuthDeviceByUserCode string // default: "resolvespec_oauth_device_by_user_code"
OAuthDeviceDecide string // default: "resolvespec_oauth_device_decide"
OAuthDevicePoll string // default: "resolvespec_oauth_device_poll"
OAuthSavePAR string // default: "resolvespec_oauth_save_par"
OAuthConsumePAR string // default: "resolvespec_oauth_consume_par"
OAuthSeenJTI string // default: "resolvespec_oauth_seen_jti"
// Keystore procedures (KeyStore)
KeystoreGetUserKeys string // default: "resolvespec_keystore_get_user_keys"
@@ -112,6 +132,23 @@ func DefaultProcNames() ProcNames {
OAuthExchangeCode: "resolvespec_oauth_exchange_code",
OAuthIntrospect: "resolvespec_oauth_introspect",
OAuthRevoke: "resolvespec_oauth_revoke",
OAuthUpdateClient: "resolvespec_oauth_update_client",
OAuthDeleteClient: "resolvespec_oauth_delete_client",
OAuthSaveConsent: "resolvespec_oauth_save_consent",
OAuthGetConsent: "resolvespec_oauth_get_consent",
OAuthRevokeConsent: "resolvespec_oauth_revoke_consent",
OAuthSaveRefresh: "resolvespec_oauth_save_refresh",
OAuthRotateRefresh: "resolvespec_oauth_rotate_refresh",
OAuthPeekRefresh: "resolvespec_oauth_peek_refresh",
OAuthRevokeRefreshFamily: "resolvespec_oauth_revoke_refresh_family",
OAuthRevokeRefreshByUser: "resolvespec_oauth_revoke_refresh_session",
OAuthCreateDevice: "resolvespec_oauth_create_device",
OAuthDeviceByUserCode: "resolvespec_oauth_device_by_user_code",
OAuthDeviceDecide: "resolvespec_oauth_device_decide",
OAuthDevicePoll: "resolvespec_oauth_device_poll",
OAuthSavePAR: "resolvespec_oauth_save_par",
OAuthConsumePAR: "resolvespec_oauth_consume_par",
OAuthSeenJTI: "resolvespec_oauth_seen_jti",
KeystoreGetUserKeys: "resolvespec_keystore_get_user_keys",
KeystoreCreateKey: "resolvespec_keystore_create_key",
KeystoreDeleteKey: "resolvespec_keystore_delete_key",
+63 -2
View File
@@ -26,6 +26,11 @@ const (
EntityUserPasswordResets Entity = "user_password_resets"
EntityOAuthClients Entity = "oauth_clients"
EntityOAuthCodes Entity = "oauth_codes"
EntityOAuthConsents Entity = "oauth_consents"
EntityOAuthRefreshTokens Entity = "oauth_refresh_tokens" //nolint:gosec // table name, not a credential
EntityOAuthDeviceCodes Entity = "oauth_device_codes"
EntityOAuthPARRequests Entity = "oauth_par_requests"
EntityOAuthJTI Entity = "oauth_jti"
EntityUserKeys Entity = "user_keys"
EntitySecGroupMembers Entity = "sec_group_members"
EntitySecColumnRules Entity = "sec_column_rules"
@@ -121,6 +126,7 @@ var (
OAuthClientsClientSecretHash = col(EntityOAuthClients, "client_secret_hash")
OAuthClientsTokenEndpointAuthMethod = col(EntityOAuthClients, "token_endpoint_auth_method")
OAuthClientsIsActive = col(EntityOAuthClients, "is_active")
OAuthClientsMetadata = col(EntityOAuthClients, "metadata")
OAuthClientsCreatedAt = col(EntityOAuthClients, "created_at")
OAuthCodesID = col(EntityOAuthCodes, "id")
@@ -135,6 +141,51 @@ var (
OAuthCodesScopes = col(EntityOAuthCodes, "scopes")
OAuthCodesExpiresAt = col(EntityOAuthCodes, "expires_at")
OAuthCodesCreatedAt = col(EntityOAuthCodes, "created_at")
OAuthCodesExtra = col(EntityOAuthCodes, "extra")
OAuthConsentsID = col(EntityOAuthConsents, "id")
OAuthConsentsUserID = col(EntityOAuthConsents, "user_id")
OAuthConsentsClientID = col(EntityOAuthConsents, "client_id")
OAuthConsentsScopes = col(EntityOAuthConsents, "scopes")
OAuthConsentsCreatedAt = col(EntityOAuthConsents, "created_at")
OAuthConsentsExpiresAt = col(EntityOAuthConsents, "expires_at")
OAuthRefreshID = col(EntityOAuthRefreshTokens, "id")
OAuthRefreshTokenHash = col(EntityOAuthRefreshTokens, "token_hash")
OAuthRefreshFamilyID = col(EntityOAuthRefreshTokens, "family_id")
OAuthRefreshClientID = col(EntityOAuthRefreshTokens, "client_id")
OAuthRefreshUserID = col(EntityOAuthRefreshTokens, "user_id")
OAuthRefreshSessionToken = col(EntityOAuthRefreshTokens, "session_token")
OAuthRefreshScopes = col(EntityOAuthRefreshTokens, "scopes")
OAuthRefreshExtra = col(EntityOAuthRefreshTokens, "extra")
OAuthRefreshCreatedAt = col(EntityOAuthRefreshTokens, "created_at")
OAuthRefreshExpiresAt = col(EntityOAuthRefreshTokens, "expires_at")
OAuthRefreshUsedAt = col(EntityOAuthRefreshTokens, "used_at")
OAuthRefreshRevokedAt = col(EntityOAuthRefreshTokens, "revoked_at")
OAuthDeviceID = col(EntityOAuthDeviceCodes, "id")
OAuthDeviceHash = col(EntityOAuthDeviceCodes, "device_hash")
OAuthDeviceUserCode = col(EntityOAuthDeviceCodes, "user_code")
OAuthDeviceClientID = col(EntityOAuthDeviceCodes, "client_id")
OAuthDeviceScopes = col(EntityOAuthDeviceCodes, "scopes")
OAuthDeviceStatus = col(EntityOAuthDeviceCodes, "status")
OAuthDeviceUserID = col(EntityOAuthDeviceCodes, "user_id")
OAuthDeviceSessionToken = col(EntityOAuthDeviceCodes, "session_token")
OAuthDeviceInterval = col(EntityOAuthDeviceCodes, "poll_interval")
OAuthDeviceCreatedAt = col(EntityOAuthDeviceCodes, "created_at")
OAuthDeviceExpiresAt = col(EntityOAuthDeviceCodes, "expires_at")
OAuthDeviceLastPolledAt = col(EntityOAuthDeviceCodes, "last_polled_at")
OAuthPARID = col(EntityOAuthPARRequests, "id")
OAuthPARRequestURI = col(EntityOAuthPARRequests, "request_uri")
OAuthPARClientID = col(EntityOAuthPARRequests, "client_id")
OAuthPARParams = col(EntityOAuthPARRequests, "params")
OAuthPARCreatedAt = col(EntityOAuthPARRequests, "created_at")
OAuthPARExpiresAt = col(EntityOAuthPARRequests, "expires_at")
OAuthJTIID = col(EntityOAuthJTI, "id")
OAuthJTIKey = col(EntityOAuthJTI, "jti_key")
OAuthJTIExpiresAt = col(EntityOAuthJTI, "expires_at")
KeysID = col(EntityUserKeys, "id")
KeysUserID = col(EntityUserKeys, "user_id")
@@ -190,10 +241,20 @@ var allColumns = []Column{
ResetsID, ResetsUserID, ResetsTokenHash, ResetsExpiresAt, ResetsCreatedAt, ResetsUsed, ResetsUsedAt,
OAuthClientsID, OAuthClientsClientID, OAuthClientsRedirectURIs, OAuthClientsClientName, OAuthClientsGrantTypes,
OAuthClientsAllowedScopes, OAuthClientsClientSecretHash, OAuthClientsTokenEndpointAuthMethod,
OAuthClientsIsActive, OAuthClientsCreatedAt,
OAuthClientsIsActive, OAuthClientsCreatedAt, OAuthClientsMetadata,
OAuthCodesID, OAuthCodesCode, OAuthCodesClientID, OAuthCodesRedirectURI, OAuthCodesClientState,
OAuthCodesCodeChallenge, OAuthCodesCodeChallengeMethod, OAuthCodesSessionToken, OAuthCodesRefreshToken,
OAuthCodesScopes, OAuthCodesExpiresAt, OAuthCodesCreatedAt,
OAuthCodesScopes, OAuthCodesExpiresAt, OAuthCodesCreatedAt, OAuthCodesExtra,
OAuthConsentsID, OAuthConsentsUserID, OAuthConsentsClientID, OAuthConsentsScopes, OAuthConsentsCreatedAt,
OAuthConsentsExpiresAt,
OAuthRefreshID, OAuthRefreshTokenHash, OAuthRefreshFamilyID, OAuthRefreshClientID, OAuthRefreshUserID,
OAuthRefreshSessionToken, OAuthRefreshScopes, OAuthRefreshExtra, OAuthRefreshCreatedAt, OAuthRefreshExpiresAt,
OAuthRefreshUsedAt, OAuthRefreshRevokedAt,
OAuthDeviceID, OAuthDeviceHash, OAuthDeviceUserCode, OAuthDeviceClientID, OAuthDeviceScopes, OAuthDeviceStatus,
OAuthDeviceUserID, OAuthDeviceSessionToken, OAuthDeviceInterval, OAuthDeviceCreatedAt, OAuthDeviceExpiresAt,
OAuthDeviceLastPolledAt,
OAuthPARID, OAuthPARRequestURI, OAuthPARClientID, OAuthPARParams, OAuthPARCreatedAt, OAuthPARExpiresAt,
OAuthJTIID, OAuthJTIKey, OAuthJTIExpiresAt,
KeysID, KeysUserID, KeysKeyType, KeysKeyHash, KeysName, KeysScopes, KeysMeta, KeysExpiresAt,
KeysCreatedAt, KeysLastUsedAt, KeysIsActive,
GroupMembersGroupID, GroupMembersUserID,