mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-03 03:51:59 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, §ypes.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, §ypes.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 §ypes.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 := §ypes.OAuthServerClient{
|
||||
ClientID: clientID,
|
||||
ClientName: name.String,
|
||||
ClientSecretHash: secret.String,
|
||||
TokenEndpointAuthMethod: method.String,
|
||||
res := §ypes.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¶ms, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user