Add OAuth2 API scope registration and enforcement

Register v1 API scopes in coredata, advertise them in OIDC discovery
and protected-resource metadata, show them on the consent screen, and
enforce scope-to-action mapping in the IAM Authorizer before policy
evaluation.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-15 17:33:15 +02:00
parent 25151fa089
commit 3ebb221a9b
56 changed files with 1918 additions and 290 deletions

View File

@@ -22,6 +22,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/bearertoken"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/iam/oauth2"
)
func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) http.Handler {
@@ -53,6 +54,7 @@ func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) ht
}
ctx = ContextWithIdentity(ctx, identity)
ctx = oauth2.ContextWithAccessToken(ctx, accessToken)
httpserver.LoggerFromContext(ctx).InfoCtx(
ctx,

View File

@@ -28,9 +28,9 @@ import (
type (
AuthorizeFuncOption func(*iam.AuthorizeParams)
AuthorizeFunc func(context.Context, gid.GID, string, ...AuthorizeFuncOption) (*coredata.Scope, error)
AuthorizeFunc func(context.Context, gid.GID, iam.Action, ...AuthorizeFuncOption) (*coredata.Scope, error)
BatchAuthorizeFuncOption func(*iam.AuthorizeBatchParams)
BatchAuthorizeFunc func(context.Context, string, []gid.GID, ...BatchAuthorizeFuncOption) (*coredata.Scope, error)
BatchAuthorizeFunc func(context.Context, iam.Action, []gid.GID, ...BatchAuthorizeFuncOption) (*coredata.Scope, error)
)
func WithAttr(key, value string) AuthorizeFuncOption {
@@ -78,7 +78,7 @@ func NewAuthorizeFunc(
return func(
ctx context.Context,
objectID gid.GID,
action string,
action iam.Action,
options ...AuthorizeFuncOption,
) (*coredata.Scope, error) {
identity := authn.IdentityFromContext(ctx)
@@ -108,6 +108,10 @@ func NewAuthorizeFunc(
return nil, gqlutils.Forbidden(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok {
return nil, gqlutils.Forbidden(ctx, err)
}
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFoundf(ctx, "resource not found")
}
@@ -127,7 +131,7 @@ func NewBatchAuthorizeFunc(
) BatchAuthorizeFunc {
return func(
ctx context.Context,
action string,
action iam.Action,
objectIDs []gid.GID,
options ...BatchAuthorizeFuncOption,
) (*coredata.Scope, error) {
@@ -158,6 +162,10 @@ func NewBatchAuthorizeFunc(
return nil, gqlutils.Forbidden(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok {
return nil, gqlutils.Forbidden(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrMixedOrganizationBatch](err); ok {
return nil, gqlutils.Invalid(ctx, err)
}

View File

@@ -16,7 +16,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
@@ -176,7 +176,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return nil, gqlutils.NotFound(ctx, err)
}
if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok {
if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok {
return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description())
}

View File

@@ -21,7 +21,7 @@ import (
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
)
@@ -35,13 +35,13 @@ func (h *OAuth2Handler) handleAuthorizeError(w http.ResponseWriter, r *http.Requ
}
func (h *OAuth2Handler) renderOAuth2ErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err)
if !ok {
httpserver.RenderError(w, http.StatusInternalServerError, err)
return
}
if errors.Is(err, oauth2server.ErrServerError) {
if errors.Is(err, oauth2.ErrServerError) {
h.logger.ErrorCtx(r.Context(), "oauth2 server error", log.Error(err))
}
@@ -54,15 +54,15 @@ func (h *OAuth2Handler) renderOAuth2ErrorResponse(w http.ResponseWriter, r *http
}
func isRedirectableError(err error) bool {
return errors.Is(err, oauth2server.ErrAccessDenied) ||
errors.Is(err, oauth2server.ErrInvalidRequest) ||
errors.Is(err, oauth2server.ErrInvalidScope) ||
errors.Is(err, oauth2server.ErrUnauthorizedClient) ||
errors.Is(err, oauth2server.ErrInvalidGrant) ||
errors.Is(err, oauth2server.ErrUnsupportedGrantType)
return errors.Is(err, oauth2.ErrAccessDenied) ||
errors.Is(err, oauth2.ErrInvalidRequest) ||
errors.Is(err, oauth2.ErrInvalidScope) ||
errors.Is(err, oauth2.ErrUnauthorizedClient) ||
errors.Is(err, oauth2.ErrInvalidGrant) ||
errors.Is(err, oauth2.ErrUnsupportedGrantType)
}
func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int {
func oauth2ErrorStatusCode(err *oauth2.OAuth2Error) int {
switch err.ErrorCode() {
case "access_denied":
return http.StatusForbidden
@@ -75,22 +75,22 @@ func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int {
}
}
func toOAuth2Error(err error) *oauth2server.OAuth2Error {
func toOAuth2Error(err error) *oauth2.OAuth2Error {
switch {
case errors.Is(err, oauth2server.ErrClientNotFound):
return oauth2server.NewError(oauth2server.ErrInvalidClient, oauth2server.WithDescription("client not found"))
case errors.Is(err, oauth2server.ErrInvalidRedirectURI):
return oauth2server.ErrInvalidRedirectURI
case errors.Is(err, oauth2server.ErrUnauthorizedMember):
return oauth2server.NewError(oauth2server.ErrUnauthorizedClient, oauth2server.WithDescription("client is private and user is not a member of the organization"))
case errors.Is(err, oauth2server.ErrDeviceCodeNotPending):
return oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("device code is not pending"))
case errors.Is(err, oauth2.ErrClientNotFound):
return oauth2.NewError(oauth2.ErrInvalidClient, oauth2.WithDescription("client not found"))
case errors.Is(err, oauth2.ErrInvalidRedirectURI):
return oauth2.ErrInvalidRedirectURI
case errors.Is(err, oauth2.ErrUnauthorizedMember):
return oauth2.NewError(oauth2.ErrUnauthorizedClient, oauth2.WithDescription("client is private and user is not a member of the organization"))
case errors.Is(err, oauth2.ErrDeviceCodeNotPending):
return oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("device code is not pending"))
default:
if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok {
if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok {
return oauthErr
}
return oauth2server.NewError(oauth2server.ErrServerError, oauth2server.WithDescription("internal error"))
return oauth2.NewError(oauth2.ErrServerError, oauth2.WithDescription("internal error"))
}
}
@@ -101,7 +101,7 @@ func redirectWithError(w http.ResponseWriter, r *http.Request, redirectURI, stat
return
}
oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err)
if !ok {
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
return

View File

@@ -15,7 +15,6 @@
package connect_v1
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -30,18 +29,13 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
"go.probo.inc/probo/pkg/uri"
)
var (
oauth2ClientContextKey = &ctxKey{name: "oauth2_client"}
oauth2AccessTokenContextKey = &ctxKey{name: "oauth2_access_token"}
)
type OAuth2Handler struct {
iam *iam.Service
sessionCookie *authn.Cookie
@@ -63,29 +57,17 @@ func NewOAuth2Handler(
}
}
// oauth2ClientFromContext returns the authenticated OAuth2 client from context.
func oauth2ClientFromContext(r *http.Request) *coredata.OAuth2Client {
client, _ := r.Context().Value(oauth2ClientContextKey).(*coredata.OAuth2Client)
return client
}
// oauth2AccessTokenFromContext returns the validated OAuth2 access token from context.
func oauth2AccessTokenFromContext(r *http.Request) *coredata.OAuth2AccessToken {
token, _ := r.Context().Value(oauth2AccessTokenContextKey).(*coredata.OAuth2AccessToken)
return token
}
// ClientAuthMiddleware authenticates the OAuth2 client from HTTP Basic auth
// or POST body credentials and stores it in the request context.
func (h *OAuth2Handler) ClientAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
client, err := h.authenticateClient(r)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
return
}
ctx := context.WithValue(r.Context(), oauth2ClientContextKey, client)
ctx := oauth2.ContextWithClient(r.Context(), client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -110,15 +92,15 @@ func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler {
return
}
ctx := context.WithValue(r.Context(), oauth2AccessTokenContextKey, accessToken)
ctx := oauth2.ContextWithAccessToken(r.Context(), accessToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (h *OAuth2Handler) endpoints() oauth2server.Endpoints {
func (h *OAuth2Handler) endpoints() oauth2.Endpoints {
api := h.baseURL.String() + "/api/connect/v1"
return oauth2server.Endpoints{
return oauth2.Endpoints{
Authorization: uri.URI(api + "/oauth2/authorize"),
Token: uri.URI(api + "/oauth2/token"),
Userinfo: uri.URI(api + "/oauth2/userinfo"),
@@ -135,7 +117,7 @@ func (h *OAuth2Handler) endpoints() oauth2server.Endpoints {
// DiscoveryHandler serves the OpenID Connect Discovery document.
// GET /.well-known/openid-configuration
func (h *OAuth2Handler) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
metadata := h.iam.OAuth2ServerService.Metadata(h.endpoints())
metadata := h.iam.OAuth2ServerMetadata(h.endpoints())
PublicCache(w, 1*time.Hour)
httpserver.RenderJSON(w, http.StatusOK, metadata)
@@ -168,7 +150,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
var in types.OAuth2AuthorizeInput
if err := in.DecodeQuery(r.URL.Query()); err != nil {
h.handleAuthorizeError(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)), "", "")
h.handleAuthorizeError(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)), "", "")
return
}
@@ -181,7 +163,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
code, err := h.iam.OAuth2ServerService.Authorize(
r.Context(),
&oauth2server.AuthorizeRequest{
&oauth2.AuthorizeRequest{
IdentityID: identity.ID,
SessionID: session.ID,
ResponseType: in.ResponseType,
@@ -196,7 +178,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
},
)
if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok {
if consentErr, ok := errors.AsType[*oauth2.ConsentRequiredError](err); ok {
consentURL := h.baseURL.WithPath("/auth/consent").
WithQuery("consent_id", consentErr.ConsentID.String()).
MustString()
@@ -217,7 +199,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid form data")))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid form data")))
return
}
@@ -227,7 +209,7 @@ func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) {
)
if err := grantType.UnmarshalText([]byte(value)); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrUnsupportedGrantType)
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrUnsupportedGrantType)
return
}
@@ -244,13 +226,15 @@ func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) {
}
func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request) {
var (
client = oauth2ClientFromContext(r)
in = types.OAuth2IntrospectInput{}
)
client, ok := oauth2.ClientFromContext(r.Context())
if !ok {
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
return
}
in := types.OAuth2IntrospectInput{}
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
return
}
@@ -270,12 +254,12 @@ func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request
func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) {
var (
client = oauth2ClientFromContext(r)
in = types.OAuth2RevokeInput{}
client, _ = oauth2.ClientFromContext(r.Context())
in = types.OAuth2RevokeInput{}
)
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
return
}
@@ -300,7 +284,7 @@ func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) {
func (h *OAuth2Handler) DeviceAuthHandler(w http.ResponseWriter, r *http.Request) {
in := types.OAuth2DeviceAuthInput{}
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
return
}
@@ -345,7 +329,7 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request)
h.renderOAuth2ErrorResponse(
w,
r,
oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid JSON body")),
oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid JSON body")),
)
return
@@ -369,15 +353,15 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request)
if len(in.Scopes) == 0 {
in.Scopes = coredata.OAuth2Scopes{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeProfile,
coredata.OAuth2ScopeEmail,
oauth2.ScopeOpenID,
oauth2.ScopeProfile,
oauth2.ScopeEmail,
}
}
clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient(
r.Context(),
&oauth2server.RegisterClientRequest{
&oauth2.RegisterClientRequest{
IdentityID: identity.ID,
OrganizationID: in.OrganizationID,
ClientName: in.ClientName,
@@ -417,7 +401,13 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request)
// UserInfoHandler serves the OIDC UserInfo endpoint.
// GET /oauth2/userinfo
func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) {
accessToken := oauth2AccessTokenFromContext(r)
accessToken, ok := oauth2.AccessTokenFromContext(r.Context())
if !ok {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, err := h.iam.OAuth2ServerService.UserInfo(
r.Context(),
@@ -425,7 +415,7 @@ func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request)
accessToken.Scopes,
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrServerError)
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrServerError)
return
}
@@ -452,12 +442,12 @@ func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Cli
}
if clientIDStr == "" {
return nil, oauth2server.ErrInvalidClient
return nil, oauth2.ErrInvalidClient
}
clientID, err := gid.ParseGID(clientIDStr)
if err != nil {
return nil, oauth2server.ErrInvalidClient
return nil, oauth2.ErrInvalidClient
}
return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientID, clientSecret)
@@ -466,13 +456,13 @@ func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Cli
func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
client, err := h.authenticateClient(r)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
return
}
var in types.OAuth2AuthorizationCodeGrantInput
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err)))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithError(err)))
return
}
@@ -484,7 +474,7 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h
in.CodeVerifier,
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired code")))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("invalid or expired code")))
return
}
@@ -495,19 +485,19 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h
func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
client, err := h.authenticateClient(r)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
return
}
var in types.OAuth2RefreshTokenGrantInput
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err)))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithError(err)))
return
}
result, err := h.iam.OAuth2ServerService.RefreshToken(r.Context(), client, in.RefreshToken)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired refresh token")))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("invalid or expired refresh token")))
return
}
@@ -518,7 +508,7 @@ func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.R
func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request) {
var in types.OAuth2DeviceCodeGrantInput
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)))
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
return
}
@@ -536,7 +526,7 @@ func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Req
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
}
func tokenResultToResponse(r *oauth2server.TokenResult) *types.OAuth2TokenResponse {
func tokenResultToResponse(r *oauth2.TokenResult) *types.OAuth2TokenResponse {
return &types.OAuth2TokenResponse{
AccessToken: r.AccessToken,
TokenType: r.TokenType,

View File

@@ -12,7 +12,7 @@ import (
"strings"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
@@ -39,13 +39,13 @@ func (r *mutationResolver) AuthorizeDevice(ctx context.Context, input types.Auth
err := r.iam.OAuth2ServerService.AuthorizeDevice(ctx, identity.ID, session.ID, userCode)
if err != nil {
if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok {
if consentErr, ok := errors.AsType[*oauth2.ConsentRequiredError](err); ok {
return &types.AuthorizeDevicePayload{
ConsentID: &consentErr.ConsentID,
}, nil
}
if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok {
if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok {
return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description())
}
@@ -66,7 +66,7 @@ func (r *mutationResolver) ApproveConsent(ctx context.Context, input types.Appro
result, err := r.iam.OAuth2ServerService.ApproveConsent(
ctx,
&oauth2server.ConsentApprovalRequest{
&oauth2.ConsentApprovalRequest{
ConsentID: input.ConsentID,
IdentityID: identity.ID,
SessionID: session.ID,

View File

@@ -21,7 +21,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/uri"
)
@@ -328,7 +328,7 @@ func InactiveIntrospectResponse() *OAuth2IntrospectResponse {
return &OAuth2IntrospectResponse{Active: false}
}
func ActiveIntrospectResponse(result *oauth2server.IntrospectResult) *OAuth2IntrospectResponse {
func ActiveIntrospectResponse(result *oauth2.IntrospectResult) *OAuth2IntrospectResponse {
return &OAuth2IntrospectResponse{
Active: true,
Scope: result.Scopes,

View File

@@ -16,7 +16,6 @@ import (
"go.probo.inc/probo/pkg/agentrun"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
@@ -33,7 +32,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
switch id.EntityType() {
case coredata.OrganizationEntityType:
action = iam.ActionOrganizationGet
action = probo.ActionOrganizationGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
organization, err := r.probo.Organizations.Get(ctx, scope, id)
if err != nil {
@@ -547,7 +546,7 @@ func (r *queryResolver) CommonThirdParties(ctx context.Context, name string) ([]
func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.ConnectorProviderInfo, error) {
identity := authn.IdentityFromContext(ctx)
if _, err := r.authorize(ctx, identity.ID, probo.ActionAccessReviewDriverCatalogList); err != nil {
if _, err := r.authorize(ctx, identity.ID, accessreview.ActionDriverCatalogList); err != nil {
return nil, err
}

View File

@@ -22,7 +22,7 @@ import (
"go.probo.inc/probo/pkg/connector"
)
// oauthClientMetadata is the OAuth Client ID Metadata Document (CIMD)
// oauth2ClientMetadata is the OAuth2 Client ID Metadata Document (CIMD)
// published for public-client connectors. The deployment's
// (baseURL + CIMDMetadataPath) URL is the OAuth client_id; providers such as
// PostHog fetch this document server-to-server during authorization to learn
@@ -37,7 +37,7 @@ const (
proboLogoURI = "https://www.probo.com/probo-logo-only.svg"
)
type oauthClientMetadata struct {
type oauth2ClientMetadata struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
ClientURI string `json:"client_uri"`
@@ -48,11 +48,11 @@ type oauthClientMetadata struct {
ResponseTypes []string `json:"response_types"`
}
// handleConnectorOAuthClientMetadata serves the public, unauthenticated CIMD
// document. It is intentionally outside the auth middleware group: the OAuth
// handleConnectorOAuth2ClientMetadata serves the public, unauthenticated CIMD
// document. It is intentionally outside the auth middleware group: the OAuth2
// provider fetches it without any Probo credentials.
func handleConnectorOAuthClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc {
doc := oauthClientMetadata{
func handleConnectorOAuth2ClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc {
doc := oauth2ClientMetadata{
ClientID: baseURL.WithPath(connector.CIMDMetadataPath).MustString(),
ClientName: "Probo",
ClientURI: proboBrandURI,

View File

@@ -25,18 +25,18 @@ import (
"go.probo.inc/probo/pkg/baseurl"
)
// TestHandleConnectorOAuthClientMetadata verifies the public CIMD document:
// TestHandleConnectorOAuth2ClientMetadata verifies the public CIMD document:
// PostHog fetches it server-to-server during authorization, so client_id,
// redirect_uris (derived from the deployment base URL) and the public-client
// token_endpoint_auth_method must be exactly right or the OAuth flow breaks.
func TestHandleConnectorOAuthClientMetadata(t *testing.T) {
func TestHandleConnectorOAuth2ClientMetadata(t *testing.T) {
t.Parallel()
base, err := baseurl.Parse("https://probo.example.com")
require.NoError(t, err)
rec := httptest.NewRecorder()
handleConnectorOAuthClientMetadata(base)(
handleConnectorOAuth2ClientMetadata(base)(
rec,
httptest.NewRequest(http.MethodGet, "/api/console/v1/connectors/oauth-client-metadata", nil),
)

View File

@@ -35,7 +35,7 @@ func NewAuthorizeFunc(logger *log.Logger) authz.AuthorizeFunc {
return func(
ctx context.Context,
objectID gid.GID,
action string,
action iam.Action,
options ...authz.AuthorizeFuncOption,
) (*coredata.Scope, error) {
loaders := FromContext(ctx)
@@ -66,6 +66,10 @@ func NewAuthorizeFunc(logger *log.Logger) authz.AuthorizeFunc {
return nil, gqlutils.Forbidden(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok {
return nil, gqlutils.Forbidden(ctx, err)
}
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFoundf(ctx, "resource not found")
}

View File

@@ -41,7 +41,7 @@ type (
// batch together.
AuthorizeKey struct {
ResourceID gid.GID
Action string
Action iam.Action
ResourceAttributes string
DryRun bool
SkipAssumptionCheck bool

View File

@@ -142,7 +142,7 @@ func NewMux(
// is fetched server-to-server by public-client providers (PostHog)
// during authorization, with no Probo credentials. Mounted outside the
// auth group above.
r.Get("/connectors/oauth-client-metadata", handleConnectorOAuthClientMetadata(baseURL))
r.Get("/connectors/oauth-client-metadata", handleConnectorOAuth2ClientMetadata(baseURL))
return r
}

View File

@@ -157,7 +157,18 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
scope, err := h.iamSvc.Authorizer.Authorize(ctx, params)
if err != nil {
if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok {
jsonx.RenderForbidden(w)
return
}
if _, ok := errors.AsType[*iam.ErrInsufficientPermissions](err); ok {
jsonx.RenderForbidden(w)
return
}
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
return
}

View File

@@ -82,6 +82,10 @@ func (r *Resolver) Authorize(ctx context.Context, entityID gid.GID, action iam.A
return nil, fmt.Errorf("permission denied")
}
if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok {
return nil, fmt.Errorf("insufficient scope")
}
if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok {
return nil, fmt.Errorf("assumption required")
}
@@ -114,6 +118,10 @@ func (r *Resolver) AuthorizeBatch(ctx context.Context, entityIDs []gid.GID, acti
return nil, fmt.Errorf("permission denied")
}
if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok {
return nil, fmt.Errorf("insufficient scope")
}
if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok {
return nil, fmt.Errorf("assumption required")
}

View File

@@ -34,7 +34,7 @@ import (
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/geoloc"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/riskmanagement"
@@ -155,6 +155,7 @@ func (s *Server) setupRoutes(baseURL string) {
// document at the issuer root under well-known paths.
s.router.Get("/.well-known/openid-configuration", s.oidcDiscoveryHandler)
s.router.Get("/.well-known/oauth-authorization-server", s.oidcDiscoveryHandler)
s.router.Get("/.well-known/oauth-protected-resource", s.protectedResourceMetadataHandler)
s.router.Mount("/api", http.StripPrefix("/api", s.apiServer))
s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler))
@@ -182,7 +183,7 @@ func (s *Server) setExtraHeaders(w http.ResponseWriter) {
func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) {
api := s.baseURL + "/api/connect/v1"
endpoints := oauth2server.Endpoints{
endpoints := oauth2.Endpoints{
Authorization: uri.URI(api + "/oauth2/authorize"),
Token: uri.URI(api + "/oauth2/token"),
Userinfo: uri.URI(api + "/oauth2/userinfo"),
@@ -193,7 +194,15 @@ func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) {
DeviceAuthorization: uri.URI(api + "/oauth2/device"),
}
metadata := s.iamService.OAuth2ServerService.Metadata(endpoints)
metadata := s.iamService.OAuth2ServerMetadata(endpoints)
w.Header().Set("Cache-Control", "public, max-age=3600")
httpserver.RenderJSON(w, http.StatusOK, metadata)
}
func (s *Server) protectedResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
resource := uri.URI(s.baseURL)
metadata := s.iamService.OAuth2ProtectedResourceMetadata(resource)
w.Header().Set("Cache-Control", "public, max-age=3600")
httpserver.RenderJSON(w, http.StatusOK, metadata)