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:
Bryan Frimin
2026-03-30 14:49:18 +02:00
parent e84094e62c
commit 11770b4058
155 changed files with 14483 additions and 223 deletions

View File

@@ -16,6 +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/mail"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
@@ -31,6 +32,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
)
switch id.EntityType() {
case coredata.OAuth2ConsentEntityType:
action = iam.ActionOAuth2ConsentGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
consent, err := r.iam.OAuth2ServerService.GetConsentByID(ctx, id)
if err != nil {
return nil, err
}
return types.NewConsent(consent), nil
}
case coredata.OrganizationEntityType:
action = iam.ActionOrganizationGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
@@ -38,6 +49,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
if err != nil {
return nil, err
}
return types.NewOrganization(organization), nil
}
case coredata.IdentityEntityType:
@@ -157,6 +169,10 @@ 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 {
return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description())
}
r.logger.ErrorCtx(ctx, "cannot load node", log.Error(err))
return nil, gqlutils.Internal(ctx)
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2025-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 connect_v1
import (
"fmt"
"net/http"
"time"
)
func NoCache(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
func PublicCache(w http.ResponseWriter, maxAge time.Duration) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds())))
}

View File

@@ -37,4 +37,4 @@ models:
- "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar"
EmailAddr:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar"
- "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar"

View File

@@ -0,0 +1,42 @@
extend type Mutation {
authorizeDevice(
input: AuthorizeDeviceInput!
): AuthorizeDevicePayload @session(required: PRESENT)
approveConsent(
input: ApproveConsentInput!
): ApproveConsentPayload @session(required: PRESENT)
}
input AuthorizeDeviceInput {
userCode: String!
}
type AuthorizeDevicePayload {
success: Boolean!
consentId: ID
}
type Consent implements Node {
id: ID!
application: Application! @goField(forceResolver: true)
scopes: [String!]!
}
type Application implements Node {
id: ID!
name: String!
logoUrl: String
url: String
}
input ApproveConsentInput {
consentId: ID!
approved: Boolean!
}
type ApproveConsentPayload {
redirectURL: String
deviceAuthorized: Boolean
}

View File

@@ -0,0 +1,120 @@
// 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 connect_v1
import (
"errors"
"net/http"
"net/url"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam/oauth2server"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
)
func (h *OAuth2Handler) handleAuthorizeError(w http.ResponseWriter, r *http.Request, err error, redirectURI, state string) {
if isRedirectableError(err) && redirectURI != "" {
redirectWithError(w, r, redirectURI, state, err)
return
}
h.renderOAuth2ErrorResponse(w, r, err)
}
func (h *OAuth2Handler) renderOAuth2ErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
if !ok {
httpserver.RenderError(w, http.StatusInternalServerError, err)
return
}
if errors.Is(err, oauth2server.ErrServerError) {
h.logger.ErrorCtx(r.Context(), "oauth2 server error", log.Error(err))
}
NoCache(w)
httpserver.RenderJSON(w, oauth2ErrorStatusCode(oauthErr), &types.OAuth2ErrorResponse{
Code: oauthErr.ErrorCode(),
Description: oauthErr.Description(),
})
}
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)
}
func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int {
switch err.ErrorCode() {
case "access_denied":
return http.StatusForbidden
case "invalid_client":
return http.StatusUnauthorized
case "server_error":
return http.StatusInternalServerError
default:
return http.StatusBadRequest
}
}
func toOAuth2Error(err error) *oauth2server.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"))
default:
if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok {
return oauthErr
}
return oauth2server.NewError(oauth2server.ErrServerError, oauth2server.WithDescription("internal error"))
}
}
func redirectWithError(w http.ResponseWriter, r *http.Request, redirectURI, state string, err error) {
u, parseErr := url.Parse(redirectURI)
if parseErr != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
return
}
oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
if !ok {
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
return
}
q := u.Query()
q.Set("error", oauthErr.ErrorCode())
if desc := oauthErr.Description(); desc != "" {
q.Set("error_description", desc)
}
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}

View File

@@ -0,0 +1,547 @@
// 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 connect_v1
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/bearertoken"
"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/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
baseURL *baseurl.BaseURL
logger *log.Logger
}
func NewOAuth2Handler(
svc *iam.Service,
cookieConfig securecookie.Config,
baseURL *baseurl.BaseURL,
logger *log.Logger,
) *OAuth2Handler {
return &OAuth2Handler{
iam: svc,
sessionCookie: authn.NewCookie(&cookieConfig),
baseURL: baseURL,
logger: logger.Named("oauth2"),
}
}
// 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)
return
}
ctx := context.WithValue(r.Context(), oauth2ClientContextKey, client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// BearerTokenMiddleware validates the OAuth2 bearer token from the
// Authorization header and stores the access token in the request context.
func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization"))
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
accessToken, err := h.iam.OAuth2ServerService.LoadAccessToken(r.Context(), tokenValue)
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), oauth2AccessTokenContextKey, accessToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (h *OAuth2Handler) endpoints() oauth2server.Endpoints {
api := h.baseURL.String() + "/api/connect/v1"
return oauth2server.Endpoints{
Authorization: uri.URI(api + "/oauth2/authorize"),
Token: uri.URI(api + "/oauth2/token"),
Userinfo: uri.URI(api + "/oauth2/userinfo"),
JWKS: uri.URI(api + "/oauth2/jwks"),
Registration: uri.URI(api + "/oauth2/register"),
Introspection: uri.URI(api + "/oauth2/introspect"),
Revocation: uri.URI(api + "/oauth2/revoke"),
DeviceAuthorization: uri.URI(api + "/oauth2/device"),
}
}
// --- Handlers ---
// 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())
PublicCache(w, 1*time.Hour)
httpserver.RenderJSON(w, http.StatusOK, metadata)
}
// JWKSHandler serves the JSON Web Key Set.
// GET /oauth2/jwks
func (h *OAuth2Handler) JWKSHandler(w http.ResponseWriter, r *http.Request) {
jwks := h.iam.OAuth2ServerService.JWKS()
PublicCache(w, 1*time.Hour)
httpserver.RenderJSON(w, http.StatusOK, jwks)
}
// AuthorizeHandler handles the authorization endpoint.
// GET /oauth2/authorize
func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) {
identity := authn.IdentityFromContext(r.Context())
if identity == nil {
continueURL := h.baseURL.WithPath("/api/connect/v1/oauth2/authorize").
WithQueryValues(r.URL.Query()).
MustString()
loginURL := h.baseURL.WithPath("/auth/login").
WithQuery("continue", continueURL).
MustString()
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
var in types.OAuth2AuthorizeInput
if err := in.DecodeQuery(r.URL.Query()); err != nil {
h.handleAuthorizeError(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)), "", "")
return
}
session := authn.SessionFromContext(r.Context())
authTime := time.Now()
if session != nil {
authTime = session.CreatedAt
}
code, err := h.iam.OAuth2ServerService.Authorize(
r.Context(),
&oauth2server.AuthorizeRequest{
IdentityID: identity.ID,
SessionID: session.ID,
ResponseType: in.ResponseType,
ClientID: in.ClientID,
RedirectURI: in.RedirectURI,
Scopes: in.Scopes,
CodeChallenge: in.CodeChallenge,
CodeChallengeMethod: in.CodeChallengeMethod,
Nonce: in.Nonce,
State: in.State,
AuthTime: authTime,
},
)
if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok {
consentURL := h.baseURL.WithPath("/auth/consent").
WithQuery("consent_id", consentErr.ConsentID.String()).
MustString()
http.Redirect(w, r, consentURL, http.StatusFound)
return
}
if err != nil {
oauthErr := toOAuth2Error(err)
h.handleAuthorizeError(w, r, oauthErr, in.RedirectURI, in.State)
return
}
redirectWithCode(w, r, in.RedirectURI, code, in.State)
}
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")))
return
}
var (
grantType coredata.OAuth2GrantType
value = r.FormValue("grant_type")
)
if err := grantType.UnmarshalText([]byte(value)); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrUnsupportedGrantType)
return
}
switch grantType {
case coredata.OAuth2GrantTypeAuthorizationCode:
h.handleAuthorizationCodeGrant(w, r)
case coredata.OAuth2GrantTypeRefreshToken:
h.handleRefreshTokenGrant(w, r)
case coredata.OAuth2GrantTypeDeviceCode:
h.handleDeviceCodeGrant(w, r)
default:
panic(fmt.Sprintf("unsupported grant type: %s", grantType))
}
}
func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request) {
var (
client = oauth2ClientFromContext(r)
in = types.OAuth2IntrospectInput{}
)
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)))
return
}
result, err := h.iam.OAuth2ServerService.IntrospectToken(
r.Context(),
client.ID,
in.Token,
)
if err != nil || result == nil {
httpserver.RenderJSON(w, http.StatusOK, types.InactiveIntrospectResponse())
return
}
httpserver.RenderJSON(w, http.StatusOK, types.ActiveIntrospectResponse(result))
}
func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) {
var (
client = oauth2ClientFromContext(r)
in = types.OAuth2RevokeInput{}
)
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)))
return
}
if err := h.iam.OAuth2ServerService.RevokeToken(
r.Context(),
client.ID,
in.Token,
in.TokenTypeHint,
); err != nil {
h.logger.ErrorCtx(r.Context(), "cannot revoke token", log.Error(err))
w.Header().Set("Retry-After", "30")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
// DeviceAuthHandler handles the device authorization endpoint (RFC 8628).
// POST /oauth2/device
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)))
return
}
deviceCodeValue, dc, err := h.iam.OAuth2ServerService.CreateDeviceCode(
r.Context(),
in.ClientID,
in.Scopes,
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, err)
return
}
verificationURI := uri.URI(h.baseURL.WithPath("/auth/device").MustString())
verificationURIComplete := uri.URI(
h.baseURL.WithPath("/auth/device").
WithQuery("user_code", string(dc.UserCode)).
MustString(),
)
httpserver.RenderJSON(
w,
http.StatusOK,
&types.OAuth2DeviceAuthResponse{
DeviceCode: deviceCodeValue,
UserCode: dc.UserCode.Format(),
VerificationURI: verificationURI,
VerificationURIComplete: verificationURIComplete,
ExpiresIn: int(time.Until(dc.ExpiresAt).Seconds()),
Interval: dc.PollInterval,
},
)
}
// RegisterHandler handles dynamic client registration (RFC 7591).
// POST /oauth2/register
func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
identity := authn.IdentityFromContext(r.Context())
var in types.OAuth2RegisterInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
h.renderOAuth2ErrorResponse(
w,
r,
oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid JSON body")),
)
return
}
if len(in.GrantTypes) == 0 {
in.GrantTypes = []coredata.OAuth2GrantType{coredata.OAuth2GrantTypeAuthorizationCode}
}
if len(in.ResponseTypes) == 0 {
in.ResponseTypes = []coredata.OAuth2ResponseType{coredata.OAuth2ResponseTypeCode}
}
if in.TokenEndpointAuthMethod == "" {
in.TokenEndpointAuthMethod = coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic
}
if in.Visibility == "" {
in.Visibility = coredata.OAuth2ClientVisibilityPrivate
}
if len(in.Scopes) == 0 {
in.Scopes = coredata.OAuth2Scopes{
coredata.OAuth2ScopeOpenID,
coredata.OAuth2ScopeProfile,
coredata.OAuth2ScopeEmail,
}
}
clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient(
r.Context(),
&oauth2server.RegisterClientRequest{
IdentityID: identity.ID,
OrganizationID: in.OrganizationID,
ClientName: in.ClientName,
Visibility: in.Visibility,
RedirectURIs: in.RedirectURIs,
GrantTypes: in.GrantTypes,
ResponseTypes: in.ResponseTypes,
TokenEndpointAuthMethod: in.TokenEndpointAuthMethod,
LogoURI: in.LogoURI,
ClientURI: in.ClientURI,
Contacts: in.Contacts,
Scopes: in.Scopes,
},
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, err)
return
}
httpserver.RenderJSON(
w,
http.StatusCreated,
&types.OAuth2RegisterResponse{
ClientID: clientID.String(),
ClientSecret: clientSecret,
ClientName: in.ClientName,
Visibility: in.Visibility,
RedirectURIs: in.RedirectURIs,
GrantTypes: in.GrantTypes,
ResponseTypes: in.ResponseTypes,
TokenEndpointAuthMethod: in.TokenEndpointAuthMethod,
Scopes: in.Scopes,
},
)
}
// UserInfoHandler serves the OIDC UserInfo endpoint.
// GET /oauth2/userinfo
func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) {
accessToken := oauth2AccessTokenFromContext(r)
claims, err := h.iam.OAuth2ServerService.UserInfo(
r.Context(),
accessToken.IdentityID,
accessToken.Scopes,
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrServerError)
return
}
httpserver.RenderJSON(w, http.StatusOK, claims)
}
// --- Internal helpers ---
func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Client, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
var clientIDStr, clientSecret string
// Try HTTP Basic auth first.
if username, password, ok := r.BasicAuth(); ok {
clientIDStr = username
clientSecret = password
} else {
// Fall back to POST body.
clientIDStr = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if clientIDStr == "" {
return nil, oauth2server.ErrInvalidClient
}
clientID, err := gid.ParseGID(clientIDStr)
if err != nil {
return nil, oauth2server.ErrInvalidClient
}
return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientID, clientSecret)
}
func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
client, err := h.authenticateClient(r)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
return
}
var in types.OAuth2AuthorizationCodeGrantInput
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err)))
return
}
result, err := h.iam.OAuth2ServerService.ExchangeAuthorizationCode(
r.Context(),
client,
in.Code,
in.RedirectURI,
in.CodeVerifier,
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired code")))
return
}
NoCache(w)
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
}
func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
client, err := h.authenticateClient(r)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
return
}
var in types.OAuth2RefreshTokenGrantInput
if err := in.DecodeForm(r); err != nil {
h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.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")))
return
}
NoCache(w)
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
}
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)))
return
}
result, err := h.iam.OAuth2ServerService.PollDeviceCode(
r.Context(),
in.ClientID,
in.DeviceCode,
)
if err != nil {
h.renderOAuth2ErrorResponse(w, r, err)
return
}
NoCache(w)
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
}
func tokenResultToResponse(r *oauth2server.TokenResult) *types.OAuth2TokenResponse {
return &types.OAuth2TokenResponse{
AccessToken: r.AccessToken,
TokenType: r.TokenType,
ExpiresIn: r.ExpiresIn,
RefreshToken: r.RefreshToken,
IDToken: r.IDToken,
Scope: r.Scope,
}
}
func redirectWithCode(w http.ResponseWriter, r *http.Request, redirectURI, code, state string) {
u, _ := url.Parse(redirectURI)
q := u.Query()
q.Set("code", code)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}

View File

@@ -0,0 +1,126 @@
package connect_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.87
import (
"context"
"errors"
"net/url"
"strings"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam/oauth2server"
"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"
"go.probo.inc/probo/pkg/server/gqlutils"
)
// Application is the resolver for the application field.
func (r *consentResolver) Application(ctx context.Context, obj *types.Consent) (*types.Application, error) {
client, err := r.iam.OAuth2ServerService.GetClientByID(ctx, obj.Application.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load oauth2 client", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewApplication(client), nil
}
// AuthorizeDevice is the resolver for the authorizeDevice field.
func (r *mutationResolver) AuthorizeDevice(ctx context.Context, input types.AuthorizeDeviceInput) (*types.AuthorizeDevicePayload, error) {
identity := authn.IdentityFromContext(ctx)
session := authn.SessionFromContext(ctx)
userCode := strings.ToUpper(strings.TrimSpace(strings.ReplaceAll(input.UserCode, "-", "")))
err := r.iam.OAuth2ServerService.AuthorizeDevice(ctx, identity.ID, session.ID, userCode)
if err != nil {
if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok {
return &types.AuthorizeDevicePayload{
ConsentID: &consentErr.ConsentID,
}, nil
}
if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok {
return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description())
}
r.logger.ErrorCtx(ctx, "cannot authorize device", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AuthorizeDevicePayload{
Success: true,
}, nil
}
// ApproveConsent is the resolver for the approveConsent field.
func (r *mutationResolver) ApproveConsent(ctx context.Context, input types.ApproveConsentInput) (*types.ApproveConsentPayload, error) {
identity := authn.IdentityFromContext(ctx)
session := authn.SessionFromContext(ctx)
result, err := r.iam.OAuth2ServerService.ApproveConsent(
ctx,
&oauth2server.ConsentApprovalRequest{
ConsentID: input.ConsentID,
IdentityID: identity.ID,
SessionID: session.ID,
Approved: input.Approved,
AuthTime: session.CreatedAt,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot approve oauth2 consent", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if result.Denied {
if result.IsDeviceFlow {
return &types.ApproveConsentPayload{
DeviceAuthorized: new(false),
}, nil
}
u, _ := url.Parse(result.RedirectURI)
q := u.Query()
q.Set("error", "access_denied")
q.Set("error_description", "user denied the request")
if result.State != "" {
q.Set("state", result.State)
}
u.RawQuery = q.Encode()
redirectURL := u.String()
return &types.ApproveConsentPayload{
RedirectURL: &redirectURL,
}, nil
}
if result.IsDeviceFlow {
return &types.ApproveConsentPayload{
DeviceAuthorized: new(true),
}, nil
}
u, _ := url.Parse(result.RedirectURI)
q := u.Query()
q.Set("code", result.Code)
if result.State != "" {
q.Set("state", result.State)
}
u.RawQuery = q.Encode()
redirectURL := u.String()
return &types.ApproveConsentPayload{
RedirectURL: &redirectURL,
}, nil
}
// Consent returns schema.ConsentResolver implementation.
func (r *Resolver) Consent() schema.ConsentResolver { return &consentResolver{r} }
type consentResolver struct{ *Resolver }

View File

@@ -69,11 +69,12 @@ func NewMux(
sessionMiddleware := authn.NewSessionMiddleware(svc, cookieConfig)
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
graphqlHandler := NewGraphQLHandler(svc, logger, baseURL, cookieConfig)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))
router := r.With(sessionMiddleware, apiKeyMiddleware)
router := r.With(sessionMiddleware, apiKeyMiddleware, oauth2Middleware)
oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost, isTrustCenterDomain)
@@ -88,6 +89,29 @@ func NewMux(
scimServer := NewSCIMServer(scimHandler)
r.Mount("/scim/2.0", http.StripPrefix("/scim/2.0", scimHandler.BearerTokenMiddleware(scimServer)))
// OAuth2 / OpenID Connect server endpoints.
oauth2Handler := NewOAuth2Handler(svc, cookieConfig, baseURL, logger)
// Public endpoints (no authentication).
r.Get("/oauth2/jwks", oauth2Handler.JWKSHandler)
r.Post("/oauth2/token", oauth2Handler.TokenHandler)
r.Post("/oauth2/device", oauth2Handler.DeviceAuthHandler)
// Bearer-token authenticated endpoints.
bearerAuth := r.With(oauth2Handler.BearerTokenMiddleware)
bearerAuth.Get("/oauth2/userinfo", oauth2Handler.UserInfoHandler)
// Client-authenticated endpoints.
clientAuth := r.With(oauth2Handler.ClientAuthMiddleware)
clientAuth.Post("/oauth2/introspect", oauth2Handler.IntrospectHandler)
clientAuth.Post("/oauth2/revoke", oauth2Handler.RevokeHandler)
// Session-authenticated endpoints.
router.Get("/oauth2/authorize", oauth2Handler.AuthorizeHandler)
requireIdentity := router.With(authn.NewIdentityPresenceMiddleware())
requireIdentity.Post("/oauth2/register", oauth2Handler.RegisterHandler)
return r
}

View File

@@ -0,0 +1,330 @@
// 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 types
import (
"fmt"
"net/http"
"net/url"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/uri"
)
func requireGID(values url.Values, param string) (gid.GID, error) {
v := values.Get(param)
if v == "" {
return gid.GID{}, fmt.Errorf("missing %s", param)
}
id, err := gid.ParseGID(v)
if err != nil {
return gid.GID{}, fmt.Errorf("invalid %s", param)
}
return id, nil
}
func parseScopes(s string) (coredata.OAuth2Scopes, error) {
var scopes coredata.OAuth2Scopes
if err := scopes.UnmarshalText([]byte(s)); err != nil {
return nil, err
}
return scopes, nil
}
type (
OAuth2AuthorizeInput struct {
ClientID gid.GID
RedirectURI string
State string
ResponseType coredata.OAuth2ResponseType
Scopes coredata.OAuth2Scopes
CodeChallenge string
CodeChallengeMethod coredata.OAuth2CodeChallengeMethod
Nonce string
}
OAuth2IntrospectInput struct {
Token string
}
OAuth2RevokeInput struct {
Token string
TokenTypeHint *coredata.OAuth2TokenTypeHint
}
OAuth2DeviceAuthInput struct {
ClientID gid.GID
Scopes coredata.OAuth2Scopes
}
OAuth2AuthorizationCodeGrantInput struct {
ClientID string
ClientSecret string
Code string
RedirectURI string
CodeVerifier string
}
OAuth2RefreshTokenGrantInput struct {
ClientID string
ClientSecret string
RefreshToken string
}
OAuth2DeviceCodeGrantInput struct {
ClientID gid.GID
DeviceCode string
}
OAuth2RegisterInput struct {
OrganizationID *gid.GID `json:"organization_id"`
ClientName string `json:"client_name"`
Visibility coredata.OAuth2ClientVisibility `json:"visibility"`
RedirectURIs []uri.URI `json:"redirect_uris"`
GrantTypes []coredata.OAuth2GrantType `json:"grant_types"`
ResponseTypes []coredata.OAuth2ResponseType `json:"response_types"`
TokenEndpointAuthMethod coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_method"`
LogoURI *uri.URI `json:"logo_uri"`
ClientURI *uri.URI `json:"client_uri"`
Contacts []string `json:"contacts"`
Scopes coredata.OAuth2Scopes `json:"scopes"`
}
)
func (in *OAuth2AuthorizeInput) DecodeQuery(q url.Values) error {
var err error
in.ClientID, err = requireGID(q, "client_id")
if err != nil {
return err
}
in.RedirectURI = q.Get("redirect_uri")
in.State = q.Get("state")
in.ResponseType = coredata.OAuth2ResponseType(q.Get("response_type"))
in.CodeChallenge = q.Get("code_challenge")
in.CodeChallengeMethod = coredata.OAuth2CodeChallengeMethod(q.Get("code_challenge_method"))
in.Nonce = q.Get("nonce")
in.Scopes, err = parseScopes(q.Get("scope"))
if err != nil {
return err
}
return nil
}
func (in *OAuth2IntrospectInput) DecodeForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("invalid form data")
}
in.Token = r.FormValue("token")
if in.Token == "" {
return fmt.Errorf("missing token parameter")
}
return nil
}
func (in *OAuth2RevokeInput) DecodeForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("invalid form data")
}
in.Token = r.FormValue("token")
if hint := r.FormValue("token_type_hint"); hint != "" {
h := coredata.OAuth2TokenTypeHint(hint)
if h.IsValid() {
in.TokenTypeHint = &h
}
}
return nil
}
func (in *OAuth2DeviceAuthInput) DecodeForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("invalid form data")
}
var err error
in.ClientID, err = requireGID(r.Form, "client_id")
if err != nil {
return err
}
if scopeStr := r.FormValue("scope"); scopeStr != "" {
in.Scopes, err = parseScopes(scopeStr)
if err != nil {
return fmt.Errorf("invalid scope")
}
}
return nil
}
func (in *OAuth2AuthorizationCodeGrantInput) DecodeForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("invalid form data")
}
in.ClientID = r.FormValue("client_id")
in.ClientSecret = r.FormValue("client_secret")
in.Code = r.FormValue("code")
in.RedirectURI = r.FormValue("redirect_uri")
in.CodeVerifier = r.FormValue("code_verifier")
if in.Code == "" {
return fmt.Errorf("missing code")
}
return nil
}
func (in *OAuth2RefreshTokenGrantInput) DecodeForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("invalid form data")
}
in.ClientID = r.FormValue("client_id")
in.ClientSecret = r.FormValue("client_secret")
in.RefreshToken = r.FormValue("refresh_token")
if in.RefreshToken == "" {
return fmt.Errorf("missing refresh_token")
}
return nil
}
func (in *OAuth2DeviceCodeGrantInput) DecodeForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("invalid form data")
}
var err error
in.ClientID, err = requireGID(r.Form, "client_id")
if err != nil {
return err
}
in.DeviceCode = r.FormValue("device_code")
if in.DeviceCode == "" {
return fmt.Errorf("missing device_code")
}
return nil
}
type (
OAuth2TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token,omitempty"`
Scope string `json:"scope,omitempty"`
}
OAuth2IntrospectResponse struct {
Active bool `json:"active"`
Scope coredata.OAuth2Scopes `json:"scope,omitempty"`
ClientID gid.GID `json:"client_id,omitempty"`
Sub gid.GID `json:"sub,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
TokenType string `json:"token_type,omitempty"`
}
OAuth2DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI uri.URI `json:"verification_uri"`
VerificationURIComplete uri.URI `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
OAuth2RegisterResponse struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
ClientName string `json:"client_name"`
Visibility coredata.OAuth2ClientVisibility `json:"visibility"`
RedirectURIs []uri.URI `json:"redirect_uris"`
GrantTypes []coredata.OAuth2GrantType `json:"grant_types"`
ResponseTypes []coredata.OAuth2ResponseType `json:"response_types"`
TokenEndpointAuthMethod coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_method"`
Scopes coredata.OAuth2Scopes `json:"scopes"`
}
OAuth2ErrorResponse struct {
Code string `json:"error"`
Description string `json:"error_description,omitempty"`
}
)
func NewConsent(consent *coredata.OAuth2Consent) *Consent {
scopes := make([]string, len(consent.Scopes))
for i, s := range consent.Scopes {
scopes[i] = string(s)
}
return &Consent{
ID: consent.ID,
Application: &Application{ID: consent.ClientID},
Scopes: scopes,
}
}
func NewApplication(client *coredata.OAuth2Client) *Application {
app := &Application{
ID: client.ID,
Name: client.ClientName,
}
if client.LogoURI != nil {
s := string(*client.LogoURI)
app.LogoURL = &s
}
if client.ClientURI != nil {
s := string(*client.ClientURI)
app.URL = &s
}
return app
}
func InactiveIntrospectResponse() *OAuth2IntrospectResponse {
return &OAuth2IntrospectResponse{Active: false}
}
func ActiveIntrospectResponse(token *coredata.OAuth2AccessToken) *OAuth2IntrospectResponse {
return &OAuth2IntrospectResponse{
Active: true,
Scope: token.Scopes,
ClientID: token.ClientID,
Sub: token.IdentityID,
Exp: token.ExpiresAt.Unix(),
Iat: token.CreatedAt.Unix(),
TokenType: "Bearer",
}
}