Add OAuth2/OpenID Connect authorization server
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization server with support for authorization code flow (with PKCE), refresh token rotation, device authorization grant, dynamic client registration, token introspection, and token revocation. Includes database schema, coredata layer, service logic, HTTP handlers, OIDC discovery endpoint, and JWKS publishing. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -350,7 +350,7 @@ func (es *ElectronicSignature) computeSealV1() (string, error) {
|
||||
}
|
||||
|
||||
input := strings.Join(fields, "\n")
|
||||
return hash.SHA256Hex([]byte(input)), nil
|
||||
return hash.SHA256HexString(input), nil
|
||||
}
|
||||
|
||||
func ResetStaleCertificateProcessing(
|
||||
|
||||
@@ -102,6 +102,12 @@ const (
|
||||
CookieCategoryEntityType uint16 = 76
|
||||
CookieConsentRecordEntityType uint16 = 77
|
||||
CookieBannerVersionEntityType uint16 = 78
|
||||
OAuth2ClientEntityType uint16 = 79
|
||||
OAuth2ConsentEntityType uint16 = 80
|
||||
OAuth2AccessTokenEntityType uint16 = 81
|
||||
OAuth2RefreshTokenEntityType uint16 = 82
|
||||
OAuth2AuthorizationCodeEntityType uint16 = 83
|
||||
OAuth2DeviceCodeEntityType uint16 = 84
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -256,6 +262,18 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &CookieConsentRecord{ID: id}, true
|
||||
case CookieBannerVersionEntityType:
|
||||
return &CookieBannerVersion{ID: id}, true
|
||||
case OAuth2ClientEntityType:
|
||||
return &OAuth2Client{ID: id}, true
|
||||
case OAuth2ConsentEntityType:
|
||||
return &OAuth2Consent{ID: id}, true
|
||||
case OAuth2AccessTokenEntityType:
|
||||
return &OAuth2AccessToken{ID: id}, true
|
||||
case OAuth2RefreshTokenEntityType:
|
||||
return &OAuth2RefreshToken{ID: id}, true
|
||||
case OAuth2AuthorizationCodeEntityType:
|
||||
return &OAuth2AuthorizationCode{ID: id}, true
|
||||
case OAuth2DeviceCodeEntityType:
|
||||
return &OAuth2DeviceCode{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
107
pkg/coredata/migrations/20260406T112100Z.sql
Normal file
107
pkg/coredata/migrations/20260406T112100Z.sql
Normal file
@@ -0,0 +1,107 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- OAuth2 Authorization Server tables
|
||||
|
||||
CREATE TYPE oauth2_client_visibility AS ENUM ('private', 'public');
|
||||
CREATE TYPE oauth2_client_token_endpoint_auth_method AS ENUM ('client_secret_basic', 'client_secret_post', 'none');
|
||||
CREATE TYPE oauth2_device_code_status AS ENUM ('pending', 'authorized', 'denied', 'expired');
|
||||
|
||||
CREATE TABLE iam_oauth2_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
client_secret_hash BYTEA,
|
||||
client_name TEXT NOT NULL,
|
||||
visibility oauth2_client_visibility NOT NULL,
|
||||
redirect_uris TEXT[] NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
grant_types TEXT[] NOT NULL,
|
||||
response_types TEXT[] NOT NULL,
|
||||
token_endpoint_auth_method oauth2_client_token_endpoint_auth_method NOT NULL,
|
||||
logo_uri TEXT,
|
||||
client_uri TEXT,
|
||||
contacts TEXT[],
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_authorization_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
identity_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
nonce TEXT,
|
||||
auth_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_access_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
hashed_value BYTEA NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
identity_id TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT iam_oauth2_access_tokens_hashed_value_unique UNIQUE (hashed_value)
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
hashed_value BYTEA NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
identity_id TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
access_token_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT iam_oauth2_refresh_tokens_hashed_value_unique UNIQUE (hashed_value)
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_device_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_code_hash BYTEA NOT NULL,
|
||||
user_code TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
scopes TEXT[] NOT NULL,
|
||||
identity_id TEXT,
|
||||
status oauth2_device_code_status NOT NULL,
|
||||
last_polled_at TIMESTAMP WITH TIME ZONE,
|
||||
poll_interval INT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT iam_oauth2_device_codes_device_code_hash_unique UNIQUE (device_code_hash),
|
||||
CONSTRAINT iam_oauth2_device_codes_user_code_unique UNIQUE (user_code)
|
||||
);
|
||||
|
||||
CREATE TABLE iam_oauth2_consents (
|
||||
id TEXT PRIMARY KEY,
|
||||
identity_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES iam_oauth2_clients(id),
|
||||
scopes TEXT[] NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
approved BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
2
pkg/coredata/migrations/20260406T112200Z.sql
Normal file
2
pkg/coredata/migrations/20260406T112200Z.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE iam_oauth2_consents
|
||||
ADD COLUMN session_id TEXT NOT NULL REFERENCES iam_sessions(id);
|
||||
49
pkg/coredata/migrations/20260411T120000Z.sql
Normal file
49
pkg/coredata/migrations/20260411T120000Z.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- Allow system-level OAuth2 clients that don't belong to any tenant or
|
||||
-- organization (e.g. the Probo CLI).
|
||||
ALTER TABLE iam_oauth2_clients ALTER COLUMN tenant_id DROP NOT NULL;
|
||||
ALTER TABLE iam_oauth2_clients ALTER COLUMN organization_id DROP NOT NULL;
|
||||
|
||||
-- Well-known OAuth2 client for the Probo CLI (prb).
|
||||
-- This client is hardcoded in the CLI binary and used for the device
|
||||
-- authorization flow. Same pattern as GitHub CLI + GitHub Enterprise Server.
|
||||
INSERT INTO iam_oauth2_clients (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp',
|
||||
NULL,
|
||||
NULL,
|
||||
'Probo CLI',
|
||||
'public',
|
||||
'{}',
|
||||
'{openid,profile,email}',
|
||||
'{urn:ietf:params:oauth:grant-type:device_code,refresh_token}',
|
||||
'{code}',
|
||||
'none',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
20
pkg/coredata/migrations/20260413T232000Z.sql
Normal file
20
pkg/coredata/migrations/20260413T232000Z.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- Add offline_access scope to the Probo CLI OAuth2 client so the device
|
||||
-- authorization flow can request refresh tokens.
|
||||
UPDATE iam_oauth2_clients
|
||||
SET scopes = '{openid,profile,email,offline_access}',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp';
|
||||
19
pkg/coredata/migrations/20260414T083800Z.sql
Normal file
19
pkg/coredata/migrations/20260414T083800Z.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
ALTER TABLE iam_oauth2_consents
|
||||
ADD COLUMN IF NOT EXISTS device_code_id TEXT;
|
||||
|
||||
ALTER TABLE iam_oauth2_consents
|
||||
ALTER COLUMN redirect_uri DROP NOT NULL;
|
||||
17
pkg/coredata/migrations/20260414T140000Z.sql
Normal file
17
pkg/coredata/migrations/20260414T140000Z.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
ALTER TABLE iam_oauth2_authorization_codes
|
||||
ADD COLUMN IF NOT EXISTS redeemed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS access_token_id TEXT;
|
||||
20
pkg/coredata/migrations/20260416T120000Z.sql
Normal file
20
pkg/coredata/migrations/20260416T120000Z.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
ALTER TABLE iam_oauth2_authorization_codes
|
||||
ADD COLUMN IF NOT EXISTS hashed_value BYTEA;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS iam_oauth2_authorization_codes_hashed_value_unique
|
||||
ON iam_oauth2_authorization_codes (hashed_value)
|
||||
WHERE hashed_value IS NOT NULL;
|
||||
218
pkg/coredata/oauth2_access_token.go
Normal file
218
pkg/coredata/oauth2_access_token.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2AccessToken struct {
|
||||
ID gid.GID `db:"id"`
|
||||
HashedValue []byte `db:"hashed_value"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
IdentityID gid.GID `db:"identity_id"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
)
|
||||
|
||||
func (t *OAuth2AccessToken) ExpiresIn(now time.Time) time.Duration {
|
||||
return t.ExpiresAt.Sub(now)
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_access_tokens (
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
created_at,
|
||||
expires_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@hashed_value,
|
||||
@client_id,
|
||||
@identity_id,
|
||||
@scopes,
|
||||
@created_at,
|
||||
@expires_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": t.ID,
|
||||
"hashed_value": t.HashedValue,
|
||||
"client_id": t.ClientID,
|
||||
"identity_id": t.IdentityID,
|
||||
"scopes": t.Scopes,
|
||||
"created_at": t.CreatedAt,
|
||||
"expires_at": t.ExpiresAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) LoadByHashedValue(ctx context.Context, conn pg.Querier, hashedValue []byte) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_access_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"hashed_value": hashedValue})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AccessToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) LoadByHashedValueAndClientID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_access_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
AND client_id = @client_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"hashed_value": hashedValue,
|
||||
"client_id": clientID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AccessToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) Delete(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_access_tokens
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"id": t.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_access_token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) DeleteExpired(ctx context.Context, conn pg.Tx, now time.Time) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_access_tokens
|
||||
WHERE
|
||||
expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired oauth2_access_tokens: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (t *OAuth2AccessToken) DeleteByClientAndIdentity(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
clientID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_access_tokens
|
||||
WHERE
|
||||
client_id = @client_id
|
||||
AND identity_id = @identity_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"client_id": clientID,
|
||||
"identity_id": identityID,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete oauth2_access_tokens by client and identity: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
217
pkg/coredata/oauth2_authorization_code.go
Normal file
217
pkg/coredata/oauth2_authorization_code.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type OAuth2AuthorizationCode struct {
|
||||
ID gid.GID `db:"id"`
|
||||
HashedValue []byte `db:"hashed_value"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
IdentityID gid.GID `db:"identity_id"`
|
||||
RedirectURI uri.URI `db:"redirect_uri"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
CodeChallenge *string `db:"code_challenge"`
|
||||
CodeChallengeMethod *OAuth2CodeChallengeMethod `db:"code_challenge_method"`
|
||||
Nonce *string `db:"nonce"`
|
||||
AuthTime time.Time `db:"auth_time"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
RedeemedAt *time.Time `db:"redeemed_at"`
|
||||
AccessTokenID *gid.GID `db:"access_token_id"`
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_authorization_codes (
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
auth_time,
|
||||
created_at,
|
||||
expires_at,
|
||||
redeemed_at,
|
||||
access_token_id
|
||||
) VALUES (
|
||||
@id,
|
||||
@hashed_value,
|
||||
@client_id,
|
||||
@identity_id,
|
||||
@redirect_uri,
|
||||
@scopes,
|
||||
@code_challenge,
|
||||
@code_challenge_method,
|
||||
@nonce,
|
||||
@auth_time,
|
||||
@created_at,
|
||||
@expires_at,
|
||||
@redeemed_at,
|
||||
@access_token_id
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"hashed_value": c.HashedValue,
|
||||
"client_id": c.ClientID,
|
||||
"identity_id": c.IdentityID,
|
||||
"redirect_uri": c.RedirectURI,
|
||||
"scopes": c.Scopes,
|
||||
"code_challenge": c.CodeChallenge,
|
||||
"code_challenge_method": c.CodeChallengeMethod,
|
||||
"nonce": c.Nonce,
|
||||
"auth_time": c.AuthTime,
|
||||
"created_at": c.CreatedAt,
|
||||
"expires_at": c.ExpiresAt,
|
||||
"redeemed_at": c.RedeemedAt,
|
||||
"access_token_id": c.AccessTokenID,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) LoadByHashForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
auth_time,
|
||||
created_at,
|
||||
expires_at,
|
||||
redeemed_at,
|
||||
access_token_id
|
||||
FROM
|
||||
iam_oauth2_authorization_codes
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
AND client_id = @client_id
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{"hashed_value": hashedValue, "client_id": clientID},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AuthorizationCode])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
*c = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) Redeem(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
now time.Time,
|
||||
accessTokenID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_authorization_codes
|
||||
SET
|
||||
redeemed_at = @redeemed_at,
|
||||
access_token_id = @access_token_id
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"redeemed_at": now,
|
||||
"access_token_id": accessTokenID,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot redeem oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
c.RedeemedAt = &now
|
||||
c.AccessTokenID = &accessTokenID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) Delete(ctx context.Context, conn pg.Querier) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_authorization_codes
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"id": c.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_authorization_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2AuthorizationCode) DeleteExpired(ctx context.Context, conn pg.Tx, now time.Time) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_authorization_codes
|
||||
WHERE
|
||||
expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired oauth2_authorization_codes: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
66
pkg/coredata/oauth2_claim.go
Normal file
66
pkg/coredata/oauth2_claim.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2Claim string
|
||||
|
||||
const (
|
||||
OAuth2ClaimIssuer OAuth2Claim = "iss"
|
||||
OAuth2ClaimSubject OAuth2Claim = "sub"
|
||||
OAuth2ClaimAudience OAuth2Claim = "aud"
|
||||
OAuth2ClaimExpiration OAuth2Claim = "exp"
|
||||
OAuth2ClaimIssuedAt OAuth2Claim = "iat"
|
||||
OAuth2ClaimAuthTime OAuth2Claim = "auth_time"
|
||||
OAuth2ClaimNonce OAuth2Claim = "nonce"
|
||||
OAuth2ClaimAtHash OAuth2Claim = "at_hash"
|
||||
OAuth2ClaimEmail OAuth2Claim = "email"
|
||||
OAuth2ClaimEmailVerified OAuth2Claim = "email_verified"
|
||||
OAuth2ClaimName OAuth2Claim = "name"
|
||||
)
|
||||
|
||||
func (c OAuth2Claim) IsValid() bool {
|
||||
switch c {
|
||||
case OAuth2ClaimIssuer,
|
||||
OAuth2ClaimSubject,
|
||||
OAuth2ClaimAudience,
|
||||
OAuth2ClaimExpiration,
|
||||
OAuth2ClaimIssuedAt,
|
||||
OAuth2ClaimAuthTime,
|
||||
OAuth2ClaimNonce,
|
||||
OAuth2ClaimAtHash,
|
||||
OAuth2ClaimEmail,
|
||||
OAuth2ClaimEmailVerified,
|
||||
OAuth2ClaimName:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c OAuth2Claim) String() string { return string(c) }
|
||||
|
||||
func (c *OAuth2Claim) UnmarshalText(text []byte) error {
|
||||
*c = OAuth2Claim(text)
|
||||
if !c.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2Claim", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c OAuth2Claim) MarshalText() ([]byte, error) {
|
||||
return []byte(c.String()), nil
|
||||
}
|
||||
384
pkg/coredata/oauth2_client.go
Normal file
384
pkg/coredata/oauth2_client.go
Normal file
@@ -0,0 +1,384 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2Client struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID *gid.GID `db:"organization_id"`
|
||||
ClientSecretHash []byte `db:"client_secret_hash"`
|
||||
ClientName string `db:"client_name"`
|
||||
Visibility OAuth2ClientVisibility `db:"visibility"`
|
||||
RedirectURIs []uri.URI `db:"redirect_uris"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
GrantTypes OAuth2GrantTypes `db:"grant_types"`
|
||||
ResponseTypes OAuth2ResponseTypes `db:"response_types"`
|
||||
TokenEndpointAuthMethod OAuth2ClientTokenEndpointAuthMethod `db:"token_endpoint_auth_method"`
|
||||
LogoURI *uri.URI `db:"logo_uri"`
|
||||
ClientURI *uri.URI `db:"client_uri"`
|
||||
Contacts []string `db:"contacts"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
OAuth2Clients []*OAuth2Client
|
||||
)
|
||||
|
||||
func (c *OAuth2Client) IsRedirectURIAllowed(rawURI string) bool {
|
||||
return slices.Contains(c.RedirectURIs, uri.URI(rawURI))
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) HasGrantType(grantType OAuth2GrantType) bool {
|
||||
return slices.Contains(c.GrantTypes, grantType)
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) AreScopesAllowed(scopes OAuth2Scopes) bool {
|
||||
return c.Scopes.ContainsAll(scopes.Values())
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) CursorKey(orderBy OAuth2ClientOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case OAuth2ClientOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(c.ID, c.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var organizationID *gid.GID
|
||||
if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query oauth2 client authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
attrs := make(map[string]string)
|
||||
if organizationID != nil {
|
||||
attrs["organization_id"] = organizationID.String()
|
||||
}
|
||||
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
client_secret_hash,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
contacts,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": clientID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query iam_oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
client, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Client])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
*c = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Clients) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[OAuth2ClientOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
client_secret_hash,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
contacts,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(
|
||||
q,
|
||||
scope.SQLFragment(),
|
||||
cursor.SQLFragment(),
|
||||
)
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query iam_oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
clients, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[OAuth2Client])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
*c = clients
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Clients) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count oauth2_clients: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_clients (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
client_secret_hash,
|
||||
client_name,
|
||||
visibility,
|
||||
redirect_uris,
|
||||
scopes,
|
||||
grant_types,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
contacts,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@client_secret_hash,
|
||||
@client_name,
|
||||
@visibility,
|
||||
@redirect_uris,
|
||||
@scopes,
|
||||
@grant_types,
|
||||
@response_types,
|
||||
@token_endpoint_auth_method,
|
||||
@logo_uri,
|
||||
@client_uri,
|
||||
@contacts,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": c.OrganizationID,
|
||||
"client_secret_hash": c.ClientSecretHash,
|
||||
"client_name": c.ClientName,
|
||||
"visibility": c.Visibility,
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"grant_types": c.GrantTypes,
|
||||
"response_types": c.ResponseTypes,
|
||||
"token_endpoint_auth_method": c.TokenEndpointAuthMethod,
|
||||
"logo_uri": c.LogoURI,
|
||||
"client_uri": c.ClientURI,
|
||||
"contacts": c.Contacts,
|
||||
"created_at": c.CreatedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_clients
|
||||
SET
|
||||
client_name = @client_name,
|
||||
visibility = @visibility,
|
||||
redirect_uris = @redirect_uris,
|
||||
scopes = @scopes,
|
||||
grant_types = @grant_types,
|
||||
response_types = @response_types,
|
||||
token_endpoint_auth_method = @token_endpoint_auth_method,
|
||||
logo_uri = @logo_uri,
|
||||
client_uri = @client_uri,
|
||||
contacts = @contacts,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"client_name": c.ClientName,
|
||||
"visibility": c.Visibility,
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"grant_types": c.GrantTypes,
|
||||
"response_types": c.ResponseTypes,
|
||||
"token_endpoint_auth_method": c.TokenEndpointAuthMethod,
|
||||
"logo_uri": c.LogoURI,
|
||||
"client_uri": c.ClientURI,
|
||||
"contacts": c.Contacts,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Client) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_clients
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": c.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
56
pkg/coredata/oauth2_client_order_field.go
Normal file
56
pkg/coredata/oauth2_client_order_field.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ClientOrderField string
|
||||
|
||||
const (
|
||||
OAuth2ClientOrderFieldCreatedAt OAuth2ClientOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (f OAuth2ClientOrderField) Column() string {
|
||||
switch f {
|
||||
case OAuth2ClientOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", f))
|
||||
}
|
||||
|
||||
func (f OAuth2ClientOrderField) IsValid() bool {
|
||||
switch f {
|
||||
case OAuth2ClientOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f OAuth2ClientOrderField) String() string {
|
||||
return string(f)
|
||||
}
|
||||
|
||||
func (f *OAuth2ClientOrderField) UnmarshalText(text []byte) error {
|
||||
*f = OAuth2ClientOrderField(text)
|
||||
if !f.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ClientOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f OAuth2ClientOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(f.String()), nil
|
||||
}
|
||||
51
pkg/coredata/oauth2_client_token_endpoint_auth_method.go
Normal file
51
pkg/coredata/oauth2_client_token_endpoint_auth_method.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ClientTokenEndpointAuthMethod string
|
||||
|
||||
const (
|
||||
OAuth2ClientTokenEndpointAuthMethodClientSecretBasic OAuth2ClientTokenEndpointAuthMethod = "client_secret_basic"
|
||||
OAuth2ClientTokenEndpointAuthMethodClientSecretPost OAuth2ClientTokenEndpointAuthMethod = "client_secret_post"
|
||||
OAuth2ClientTokenEndpointAuthMethodNone OAuth2ClientTokenEndpointAuthMethod = "none"
|
||||
)
|
||||
|
||||
func (m OAuth2ClientTokenEndpointAuthMethod) IsValid() bool {
|
||||
switch m {
|
||||
case OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
OAuth2ClientTokenEndpointAuthMethodNone:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m OAuth2ClientTokenEndpointAuthMethod) String() string { return string(m) }
|
||||
|
||||
func (m *OAuth2ClientTokenEndpointAuthMethod) UnmarshalText(text []byte) error {
|
||||
*m = OAuth2ClientTokenEndpointAuthMethod(text)
|
||||
if !m.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ClientTokenEndpointAuthMethod", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m OAuth2ClientTokenEndpointAuthMethod) MarshalText() ([]byte, error) {
|
||||
return []byte(m.String()), nil
|
||||
}
|
||||
48
pkg/coredata/oauth2_client_visibility.go
Normal file
48
pkg/coredata/oauth2_client_visibility.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ClientVisibility string
|
||||
|
||||
const (
|
||||
OAuth2ClientVisibilityPrivate OAuth2ClientVisibility = "private"
|
||||
OAuth2ClientVisibilityPublic OAuth2ClientVisibility = "public"
|
||||
)
|
||||
|
||||
func (v OAuth2ClientVisibility) IsValid() bool {
|
||||
switch v {
|
||||
case OAuth2ClientVisibilityPrivate, OAuth2ClientVisibilityPublic:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v OAuth2ClientVisibility) String() string { return string(v) }
|
||||
|
||||
func (v *OAuth2ClientVisibility) UnmarshalText(text []byte) error {
|
||||
*v = OAuth2ClientVisibility(text)
|
||||
if !v.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ClientVisibility", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v OAuth2ClientVisibility) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
47
pkg/coredata/oauth2_code_challenge_method.go
Normal file
47
pkg/coredata/oauth2_code_challenge_method.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2CodeChallengeMethod string
|
||||
|
||||
const (
|
||||
OAuth2CodeChallengeMethodS256 OAuth2CodeChallengeMethod = "S256"
|
||||
)
|
||||
|
||||
func (m OAuth2CodeChallengeMethod) IsValid() bool {
|
||||
switch m {
|
||||
case OAuth2CodeChallengeMethodS256:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m OAuth2CodeChallengeMethod) String() string { return string(m) }
|
||||
|
||||
func (m *OAuth2CodeChallengeMethod) UnmarshalText(text []byte) error {
|
||||
*m = OAuth2CodeChallengeMethod(text)
|
||||
if !m.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2CodeChallengeMethod", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m OAuth2CodeChallengeMethod) MarshalText() ([]byte, error) {
|
||||
return []byte(m.String()), nil
|
||||
}
|
||||
497
pkg/coredata/oauth2_consent.go
Normal file
497
pkg/coredata/oauth2_consent.go
Normal file
@@ -0,0 +1,497 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2Consent struct {
|
||||
ID gid.GID `db:"id"`
|
||||
IdentityID gid.GID `db:"identity_id"`
|
||||
SessionID gid.GID `db:"session_id"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
RedirectURI *uri.URI `db:"redirect_uri"`
|
||||
CodeChallenge string `db:"code_challenge"`
|
||||
CodeChallengeMethod OAuth2CodeChallengeMethod `db:"code_challenge_method"`
|
||||
Nonce string `db:"nonce"`
|
||||
State string `db:"state"`
|
||||
DeviceCodeID *gid.GID `db:"device_code_id"`
|
||||
Approved bool `db:"approved"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
OAuth2Consents []*OAuth2Consent
|
||||
)
|
||||
|
||||
func (c *OAuth2Consent) CursorKey(orderBy OAuth2ConsentOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case OAuth2ConsentOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(c.ID, c.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
SELECT
|
||||
identity_id,
|
||||
session_id
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var identityID, sessionID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, c.ID).Scan(&identityID, &sessionID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot query oauth2_consent authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"identity_id": identityID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
identity_id,
|
||||
session_id,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
state,
|
||||
device_code_id,
|
||||
approved,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
id = @id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"id": id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
*c = consent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) LoadByIDForSession(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
id gid.GID,
|
||||
identityID gid.GID,
|
||||
sessionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
identity_id,
|
||||
session_id,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
state,
|
||||
device_code_id,
|
||||
approved,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
id = @id
|
||||
AND identity_id = @identity_id
|
||||
AND session_id = @session_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"id": id,
|
||||
"identity_id": identityID,
|
||||
"session_id": sessionID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
*c = consent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) LoadByIDForSessionForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
id gid.GID,
|
||||
identityID gid.GID,
|
||||
sessionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
identity_id,
|
||||
session_id,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
state,
|
||||
device_code_id,
|
||||
approved,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
id = @id
|
||||
AND identity_id = @identity_id
|
||||
AND session_id = @session_id
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"id": id,
|
||||
"identity_id": identityID,
|
||||
"session_id": sessionID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
*c = consent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) LoadMatchingConsent(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
identityID gid.GID,
|
||||
clientID gid.GID,
|
||||
scopes OAuth2Scopes,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
identity_id,
|
||||
session_id,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
state,
|
||||
device_code_id,
|
||||
approved,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
identity_id = @identity_id
|
||||
AND client_id = @client_id
|
||||
AND approved = TRUE
|
||||
AND scopes @> @scopes
|
||||
AND scopes <@ @scopes
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"identity_id": identityID,
|
||||
"client_id": clientID,
|
||||
"scopes": scopes,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
consent, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2Consent])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
*c = consent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_consents (
|
||||
id,
|
||||
identity_id,
|
||||
session_id,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
state,
|
||||
device_code_id,
|
||||
approved,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@identity_id,
|
||||
@session_id,
|
||||
@client_id,
|
||||
@scopes,
|
||||
@redirect_uri,
|
||||
@code_challenge,
|
||||
@code_challenge_method,
|
||||
@nonce,
|
||||
@state,
|
||||
@device_code_id,
|
||||
@approved,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"identity_id": c.IdentityID,
|
||||
"session_id": c.SessionID,
|
||||
"client_id": c.ClientID,
|
||||
"scopes": c.Scopes,
|
||||
"redirect_uri": c.RedirectURI,
|
||||
"code_challenge": c.CodeChallenge,
|
||||
"code_challenge_method": c.CodeChallengeMethod,
|
||||
"nonce": c.Nonce,
|
||||
"state": c.State,
|
||||
"device_code_id": c.DeviceCodeID,
|
||||
"approved": c.Approved,
|
||||
"created_at": c.CreatedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot insert oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) Update(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_consents
|
||||
SET
|
||||
scopes = @scopes,
|
||||
approved = @approved,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"id": c.ID,
|
||||
"scopes": c.Scopes,
|
||||
"approved": c.Approved,
|
||||
"updated_at": c.UpdatedAt,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consent) Delete(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_consents
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"id": c.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_consent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consents) LoadByIdentityID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
identityID gid.GID,
|
||||
cursor *page.Cursor[OAuth2ConsentOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
identity_id,
|
||||
session_id,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
nonce,
|
||||
state,
|
||||
device_code_id,
|
||||
approved,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
identity_id = @identity_id
|
||||
AND approved = TRUE
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(
|
||||
q,
|
||||
cursor.SQLFragment(),
|
||||
)
|
||||
|
||||
args := pgx.StrictNamedArgs{"identity_id": identityID}
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_consents: %w", err)
|
||||
}
|
||||
|
||||
consents, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[OAuth2Consent])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect oauth2_consents: %w", err)
|
||||
}
|
||||
|
||||
*c = consents
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OAuth2Consents) CountByIdentityID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
identityID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
iam_oauth2_consents
|
||||
WHERE
|
||||
identity_id = @identity_id
|
||||
AND approved = TRUE;
|
||||
`
|
||||
|
||||
var count int
|
||||
err := conn.QueryRow(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{"identity_id": identityID},
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count oauth2_consents: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
56
pkg/coredata/oauth2_consent_order_field.go
Normal file
56
pkg/coredata/oauth2_consent_order_field.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2ConsentOrderField string
|
||||
|
||||
const (
|
||||
OAuth2ConsentOrderFieldCreatedAt OAuth2ConsentOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (f OAuth2ConsentOrderField) Column() string {
|
||||
switch f {
|
||||
case OAuth2ConsentOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", f))
|
||||
}
|
||||
|
||||
func (f OAuth2ConsentOrderField) IsValid() bool {
|
||||
switch f {
|
||||
case OAuth2ConsentOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (f OAuth2ConsentOrderField) String() string { return string(f) }
|
||||
|
||||
func (f *OAuth2ConsentOrderField) UnmarshalText(text []byte) error {
|
||||
*f = OAuth2ConsentOrderField(text)
|
||||
if !f.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ConsentOrderField", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f OAuth2ConsentOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(f.String()), nil
|
||||
}
|
||||
308
pkg/coredata/oauth2_device_code.go
Normal file
308
pkg/coredata/oauth2_device_code.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
// OAuth2UserCode represents a raw 8-character user code for the device flow.
|
||||
OAuth2UserCode string
|
||||
|
||||
OAuth2DeviceCode struct {
|
||||
ID gid.GID `db:"id"`
|
||||
DeviceCodeHash []byte `db:"device_code_hash"`
|
||||
UserCode OAuth2UserCode `db:"user_code"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
IdentityID *gid.GID `db:"identity_id"`
|
||||
Status OAuth2DeviceCodeStatus `db:"status"`
|
||||
LastPolledAt *time.Time `db:"last_polled_at"`
|
||||
PollInterval int `db:"poll_interval"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
)
|
||||
|
||||
// Format returns the user code formatted as XXXX-XXXX for display.
|
||||
func (c OAuth2UserCode) Format() string {
|
||||
if len(c) != 8 {
|
||||
panic(fmt.Sprintf("invalid user code length: %d", len(c)))
|
||||
}
|
||||
|
||||
return string(c[:4]) + "-" + string(c[4:])
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_device_codes (
|
||||
id,
|
||||
device_code_hash,
|
||||
user_code,
|
||||
client_id,
|
||||
scopes,
|
||||
identity_id,
|
||||
status,
|
||||
last_polled_at,
|
||||
poll_interval,
|
||||
created_at,
|
||||
expires_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@device_code_hash,
|
||||
@user_code,
|
||||
@client_id,
|
||||
@scopes,
|
||||
@identity_id,
|
||||
@status,
|
||||
@last_polled_at,
|
||||
@poll_interval,
|
||||
@created_at,
|
||||
@expires_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": d.ID,
|
||||
"device_code_hash": d.DeviceCodeHash,
|
||||
"user_code": d.UserCode,
|
||||
"client_id": d.ClientID,
|
||||
"scopes": d.Scopes,
|
||||
"identity_id": d.IdentityID,
|
||||
"status": d.Status,
|
||||
"last_polled_at": d.LastPolledAt,
|
||||
"poll_interval": d.PollInterval,
|
||||
"created_at": d.CreatedAt,
|
||||
"expires_at": d.ExpiresAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok &&
|
||||
pgErr.Code == "23505" &&
|
||||
pgErr.ConstraintName == "iam_oauth2_device_codes_user_code_unique" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot insert oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) LoadByIDForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
device_code_hash,
|
||||
user_code,
|
||||
client_id,
|
||||
scopes,
|
||||
identity_id,
|
||||
status,
|
||||
last_polled_at,
|
||||
poll_interval,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_device_codes
|
||||
WHERE
|
||||
id = @id
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"id": id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2DeviceCode])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
*d = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) LoadByUserCodeForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
userCode string,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
device_code_hash,
|
||||
user_code,
|
||||
client_id,
|
||||
scopes,
|
||||
identity_id,
|
||||
status,
|
||||
last_polled_at,
|
||||
poll_interval,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_device_codes
|
||||
WHERE
|
||||
user_code = @user_code
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"user_code": userCode})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2DeviceCode])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
*d = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) LoadByDeviceCodeHashForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
device_code_hash,
|
||||
user_code,
|
||||
client_id,
|
||||
scopes,
|
||||
identity_id,
|
||||
status,
|
||||
last_polled_at,
|
||||
poll_interval,
|
||||
created_at,
|
||||
expires_at
|
||||
FROM
|
||||
iam_oauth2_device_codes
|
||||
WHERE
|
||||
device_code_hash = @device_code_hash
|
||||
AND client_id = @client_id
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"device_code_hash": hashedValue,
|
||||
"client_id": clientID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
code, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2DeviceCode])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
*d = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) Update(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_device_codes
|
||||
SET
|
||||
status = @status,
|
||||
identity_id = @identity_id,
|
||||
last_polled_at = @last_polled_at,
|
||||
poll_interval = @poll_interval
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": d.ID,
|
||||
"status": d.Status,
|
||||
"identity_id": d.IdentityID,
|
||||
"last_polled_at": d.LastPolledAt,
|
||||
"poll_interval": d.PollInterval,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot update oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) Delete(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_device_codes
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": d.ID}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot delete oauth2_device_code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OAuth2DeviceCode) DeleteExpired(ctx context.Context, conn pg.Tx, now time.Time) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_device_codes
|
||||
WHERE
|
||||
expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired oauth2_device_codes: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
53
pkg/coredata/oauth2_device_code_status.go
Normal file
53
pkg/coredata/oauth2_device_code_status.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2DeviceCodeStatus string
|
||||
|
||||
const (
|
||||
OAuth2DeviceCodeStatusPending OAuth2DeviceCodeStatus = "pending"
|
||||
OAuth2DeviceCodeStatusAuthorized OAuth2DeviceCodeStatus = "authorized"
|
||||
OAuth2DeviceCodeStatusDenied OAuth2DeviceCodeStatus = "denied"
|
||||
OAuth2DeviceCodeStatusExpired OAuth2DeviceCodeStatus = "expired"
|
||||
)
|
||||
|
||||
func (s OAuth2DeviceCodeStatus) IsValid() bool {
|
||||
switch s {
|
||||
case OAuth2DeviceCodeStatusPending,
|
||||
OAuth2DeviceCodeStatusAuthorized,
|
||||
OAuth2DeviceCodeStatusDenied,
|
||||
OAuth2DeviceCodeStatusExpired:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s OAuth2DeviceCodeStatus) String() string { return string(s) }
|
||||
|
||||
func (s *OAuth2DeviceCodeStatus) UnmarshalText(text []byte) error {
|
||||
*s = OAuth2DeviceCodeStatus(text)
|
||||
if !s.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2DeviceCodeStatus", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s OAuth2DeviceCodeStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
66
pkg/coredata/oauth2_device_code_test.go
Normal file
66
pkg/coredata/oauth2_device_code_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestOAuth2UserCode_Format(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"formats as XXXX-XXXX",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := coredata.OAuth2UserCode("ABCDEFGH")
|
||||
assert.Equal(t, "ABCD-EFGH", code.Format())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panics on short code",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := coredata.OAuth2UserCode("ABC")
|
||||
assert.Panics(t, func() { code.Format() })
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panics on long code",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := coredata.OAuth2UserCode("ABCDEFGHIJ")
|
||||
assert.Panics(t, func() { code.Format() })
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panics on empty code",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := coredata.OAuth2UserCode("")
|
||||
assert.Panics(t, func() { code.Format() })
|
||||
},
|
||||
)
|
||||
}
|
||||
54
pkg/coredata/oauth2_grant_type.go
Normal file
54
pkg/coredata/oauth2_grant_type.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type (
|
||||
OAuth2GrantType string
|
||||
OAuth2GrantTypes []OAuth2GrantType
|
||||
)
|
||||
|
||||
const (
|
||||
OAuth2GrantTypeAuthorizationCode OAuth2GrantType = "authorization_code"
|
||||
OAuth2GrantTypeRefreshToken OAuth2GrantType = "refresh_token"
|
||||
OAuth2GrantTypeDeviceCode OAuth2GrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
)
|
||||
|
||||
func (g OAuth2GrantType) IsValid() bool {
|
||||
switch g {
|
||||
case OAuth2GrantTypeAuthorizationCode,
|
||||
OAuth2GrantTypeRefreshToken,
|
||||
OAuth2GrantTypeDeviceCode:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (g OAuth2GrantType) String() string { return string(g) }
|
||||
|
||||
func (g *OAuth2GrantType) UnmarshalText(text []byte) error {
|
||||
*g = OAuth2GrantType(text)
|
||||
if !g.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2GrantType", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g OAuth2GrantType) MarshalText() ([]byte, error) {
|
||||
return []byte(g.String()), nil
|
||||
}
|
||||
344
pkg/coredata/oauth2_refresh_token.go
Normal file
344
pkg/coredata/oauth2_refresh_token.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2RefreshToken struct {
|
||||
ID gid.GID `db:"id"`
|
||||
HashedValue []byte `db:"hashed_value"`
|
||||
ClientID gid.GID `db:"client_id"`
|
||||
IdentityID gid.GID `db:"identity_id"`
|
||||
Scopes OAuth2Scopes `db:"scopes"`
|
||||
AccessTokenID gid.GID `db:"access_token_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
RevokedAt *time.Time `db:"revoked_at"`
|
||||
}
|
||||
)
|
||||
|
||||
func (t *OAuth2RefreshToken) Insert(ctx context.Context, conn pg.Tx) error {
|
||||
q := `
|
||||
INSERT INTO iam_oauth2_refresh_tokens (
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
access_token_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
revoked_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@hashed_value,
|
||||
@client_id,
|
||||
@identity_id,
|
||||
@scopes,
|
||||
@access_token_id,
|
||||
@created_at,
|
||||
@expires_at,
|
||||
@revoked_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": t.ID,
|
||||
"hashed_value": t.HashedValue,
|
||||
"client_id": t.ClientID,
|
||||
"identity_id": t.IdentityID,
|
||||
"scopes": t.Scopes,
|
||||
"access_token_id": t.AccessTokenID,
|
||||
"created_at": t.CreatedAt,
|
||||
"expires_at": t.ExpiresAt,
|
||||
"revoked_at": t.RevokedAt,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot insert oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) LoadByHashedValue(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
hashedValue []byte,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
access_token_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
revoked_at
|
||||
FROM
|
||||
iam_oauth2_refresh_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{"hashed_value": hashedValue},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2RefreshToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) LoadByHashedValueAndClientID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
access_token_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
revoked_at
|
||||
FROM
|
||||
iam_oauth2_refresh_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
AND client_id = @client_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"hashed_value": hashedValue,
|
||||
"client_id": clientID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2RefreshToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) LoadByHashedValueForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
hashedValue []byte,
|
||||
clientID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hashed_value,
|
||||
client_id,
|
||||
identity_id,
|
||||
scopes,
|
||||
access_token_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
revoked_at
|
||||
FROM
|
||||
iam_oauth2_refresh_tokens
|
||||
WHERE
|
||||
hashed_value = @hashed_value
|
||||
AND client_id = @client_id
|
||||
FOR UPDATE;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"hashed_value": hashedValue,
|
||||
"client_id": clientID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2RefreshToken])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
*t = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) Revoke(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
now time.Time,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE iam_oauth2_refresh_tokens
|
||||
SET
|
||||
revoked_at = @revoked_at
|
||||
WHERE
|
||||
id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": t.ID,
|
||||
"revoked_at": now,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot revoke oauth2_refresh_token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) RevokeByClientAndIdentity(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
clientID gid.GID,
|
||||
identityID gid.GID,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
UPDATE iam_oauth2_refresh_tokens
|
||||
SET
|
||||
revoked_at = @revoked_at
|
||||
WHERE
|
||||
client_id = @client_id
|
||||
AND identity_id = @identity_id
|
||||
AND revoked_at IS NULL
|
||||
`
|
||||
|
||||
result, err := conn.Exec(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"client_id": clientID,
|
||||
"identity_id": identityID,
|
||||
"revoked_at": now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot revoke oauth2_refresh_tokens by client and identity: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) RevokeByAccessTokenID(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
accessTokenID gid.GID,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
UPDATE iam_oauth2_refresh_tokens
|
||||
SET
|
||||
revoked_at = @revoked_at
|
||||
WHERE
|
||||
access_token_id = @access_token_id
|
||||
AND revoked_at IS NULL
|
||||
`
|
||||
|
||||
result, err := conn.Exec(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"access_token_id": accessTokenID,
|
||||
"revoked_at": now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot revoke oauth2_refresh_tokens by access_token_id: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (t *OAuth2RefreshToken) DeleteExpired(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
DELETE FROM iam_oauth2_refresh_tokens
|
||||
WHERE
|
||||
expires_at < @now
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < @revoked_cutoff)
|
||||
`
|
||||
|
||||
result, err := conn.Exec(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"now": now,
|
||||
"revoked_cutoff": now.Add(-7 * 24 * time.Hour),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired oauth2_refresh_tokens: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
50
pkg/coredata/oauth2_response_type.go
Normal file
50
pkg/coredata/oauth2_response_type.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type (
|
||||
OAuth2ResponseType string
|
||||
OAuth2ResponseTypes []OAuth2ResponseType
|
||||
)
|
||||
|
||||
const (
|
||||
OAuth2ResponseTypeCode OAuth2ResponseType = "code"
|
||||
)
|
||||
|
||||
func (r OAuth2ResponseType) IsValid() bool {
|
||||
switch r {
|
||||
case OAuth2ResponseTypeCode:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r OAuth2ResponseType) String() string { return string(r) }
|
||||
|
||||
func (r *OAuth2ResponseType) UnmarshalText(text []byte) error {
|
||||
*r = OAuth2ResponseType(text)
|
||||
if !r.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2ResponseType", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r OAuth2ResponseType) MarshalText() ([]byte, error) {
|
||||
return []byte(r.String()), nil
|
||||
}
|
||||
119
pkg/coredata/oauth2_scope.go
Normal file
119
pkg/coredata/oauth2_scope.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuth2Scope string
|
||||
OAuth2Scopes []OAuth2Scope
|
||||
)
|
||||
|
||||
const (
|
||||
OAuth2ScopeOpenID OAuth2Scope = "openid"
|
||||
OAuth2ScopeProfile OAuth2Scope = "profile"
|
||||
OAuth2ScopeEmail OAuth2Scope = "email"
|
||||
OAuth2ScopeOfflineAccess OAuth2Scope = "offline_access"
|
||||
)
|
||||
|
||||
func (s OAuth2Scope) IsValid() bool {
|
||||
switch s {
|
||||
case OAuth2ScopeOpenID, OAuth2ScopeProfile, OAuth2ScopeEmail, OAuth2ScopeOfflineAccess:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s OAuth2Scope) String() string { return string(s) }
|
||||
|
||||
func (s *OAuth2Scope) UnmarshalText(text []byte) error {
|
||||
*s = OAuth2Scope(text)
|
||||
if !s.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2Scope", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s OAuth2Scope) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) All() iter.Seq2[int, OAuth2Scope] {
|
||||
return slices.All(s)
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) Values() iter.Seq[OAuth2Scope] {
|
||||
return slices.Values(s)
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) Contains(scope OAuth2Scope) bool {
|
||||
return slices.Contains(s, scope)
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) ContainsAll(seq iter.Seq[OAuth2Scope]) bool {
|
||||
for scope := range seq {
|
||||
if !s.Contains(scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) String() string {
|
||||
ss := make([]string, len(s))
|
||||
for i, scope := range s {
|
||||
ss[i] = scope.String()
|
||||
}
|
||||
|
||||
return strings.Join(ss, " ")
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s OAuth2Scopes) OrDefault(defaultScopes OAuth2Scopes) OAuth2Scopes {
|
||||
if len(s) == 0 {
|
||||
return defaultScopes
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *OAuth2Scopes) UnmarshalText(text []byte) error {
|
||||
str := string(text)
|
||||
if str == "" {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := strings.Fields(str)
|
||||
scopes := make(OAuth2Scopes, len(fields))
|
||||
for i, f := range fields {
|
||||
if err := scopes[i].UnmarshalText([]byte(f)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
*s = scopes
|
||||
return nil
|
||||
}
|
||||
143
pkg/coredata/oauth2_scope_test.go
Normal file
143
pkg/coredata/oauth2_scope_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestOAuth2Scope_IsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"offline_access is valid",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, coredata.OAuth2ScopeOfflineAccess.IsValid())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"unknown scope is invalid",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.False(t, coredata.OAuth2Scope("admin").IsValid())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestOAuth2Scope_UnmarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"offline_access unmarshals",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var scope coredata.OAuth2Scope
|
||||
err := scope.UnmarshalText([]byte("offline_access"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, coredata.OAuth2ScopeOfflineAccess, scope)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"invalid scope returns error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var scope coredata.OAuth2Scope
|
||||
err := scope.UnmarshalText([]byte("admin"))
|
||||
assert.Error(t, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestOAuth2Scopes_Contains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"contains offline_access",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scopes := coredata.OAuth2Scopes{
|
||||
coredata.OAuth2ScopeOpenID,
|
||||
coredata.OAuth2ScopeOfflineAccess,
|
||||
}
|
||||
assert.True(t, scopes.Contains(coredata.OAuth2ScopeOfflineAccess))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"does not contain offline_access",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scopes := coredata.OAuth2Scopes{
|
||||
coredata.OAuth2ScopeOpenID,
|
||||
coredata.OAuth2ScopeProfile,
|
||||
}
|
||||
assert.False(t, scopes.Contains(coredata.OAuth2ScopeOfflineAccess))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestOAuth2Scopes_OrDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaultScopes := coredata.OAuth2Scopes{
|
||||
coredata.OAuth2ScopeOpenID,
|
||||
coredata.OAuth2ScopeProfile,
|
||||
}
|
||||
|
||||
t.Run(
|
||||
"returns default when scopes is nil",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var scopes coredata.OAuth2Scopes
|
||||
result := scopes.OrDefault(defaultScopes)
|
||||
assert.Equal(t, defaultScopes, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns default when scopes is empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scopes := coredata.OAuth2Scopes{}
|
||||
result := scopes.OrDefault(defaultScopes)
|
||||
assert.Equal(t, defaultScopes, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns scopes when non-empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scopes := coredata.OAuth2Scopes{coredata.OAuth2ScopeEmail}
|
||||
result := scopes.OrDefault(defaultScopes)
|
||||
assert.Equal(t, scopes, result)
|
||||
},
|
||||
)
|
||||
}
|
||||
47
pkg/coredata/oauth2_signing_algorithm.go
Normal file
47
pkg/coredata/oauth2_signing_algorithm.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2SigningAlgorithm string
|
||||
|
||||
const (
|
||||
OAuth2SigningAlgorithmRS256 OAuth2SigningAlgorithm = "RS256"
|
||||
)
|
||||
|
||||
func (a OAuth2SigningAlgorithm) IsValid() bool {
|
||||
switch a {
|
||||
case OAuth2SigningAlgorithmRS256:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (a OAuth2SigningAlgorithm) String() string { return string(a) }
|
||||
|
||||
func (a *OAuth2SigningAlgorithm) UnmarshalText(text []byte) error {
|
||||
*a = OAuth2SigningAlgorithm(text)
|
||||
if !a.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2SigningAlgorithm", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a OAuth2SigningAlgorithm) MarshalText() ([]byte, error) {
|
||||
return []byte(a.String()), nil
|
||||
}
|
||||
47
pkg/coredata/oauth2_subject_type.go
Normal file
47
pkg/coredata/oauth2_subject_type.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2SubjectType string
|
||||
|
||||
const (
|
||||
OAuth2SubjectTypePublic OAuth2SubjectType = "public"
|
||||
)
|
||||
|
||||
func (s OAuth2SubjectType) IsValid() bool {
|
||||
switch s {
|
||||
case OAuth2SubjectTypePublic:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s OAuth2SubjectType) String() string { return string(s) }
|
||||
|
||||
func (s *OAuth2SubjectType) UnmarshalText(text []byte) error {
|
||||
*s = OAuth2SubjectType(text)
|
||||
if !s.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2SubjectType", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s OAuth2SubjectType) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
49
pkg/coredata/oauth2_token_type_hint.go
Normal file
49
pkg/coredata/oauth2_token_type_hint.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type OAuth2TokenTypeHint string
|
||||
|
||||
const (
|
||||
OAuth2TokenTypeHintAccessToken OAuth2TokenTypeHint = "access_token"
|
||||
OAuth2TokenTypeHintRefreshToken OAuth2TokenTypeHint = "refresh_token"
|
||||
)
|
||||
|
||||
func (h OAuth2TokenTypeHint) IsValid() bool {
|
||||
switch h {
|
||||
case OAuth2TokenTypeHintAccessToken,
|
||||
OAuth2TokenTypeHintRefreshToken:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (h OAuth2TokenTypeHint) String() string { return string(h) }
|
||||
|
||||
func (h *OAuth2TokenTypeHint) UnmarshalText(text []byte) error {
|
||||
*h = OAuth2TokenTypeHint(text)
|
||||
if !h.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OAuth2TokenTypeHint", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h OAuth2TokenTypeHint) MarshalText() ([]byte, error) {
|
||||
return []byte(h.String()), nil
|
||||
}
|
||||
@@ -16,8 +16,6 @@ package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -26,6 +24,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/crypto/rand"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
@@ -45,12 +44,12 @@ type (
|
||||
)
|
||||
|
||||
func (w *WebhookSubscription) GenerateSigningSecret(encryptionKey cipher.EncryptionKey) (string, error) {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
hexSecret, err := rand.HexString(32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate signing secret: %w", err)
|
||||
}
|
||||
|
||||
signingSecret := "whsec_" + hex.EncodeToString(secret)
|
||||
signingSecret := "whsec_" + hexSecret
|
||||
|
||||
encrypted, err := cipher.Encrypt([]byte(signingSecret), encryptionKey)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user