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:
@@ -145,6 +145,13 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
csrf.AddInsecureBypassPattern("POST /cookie-banner/v1/*")
|
||||
csrf.AddInsecureBypassPattern("OPTIONS /cookie-banner/v1/*")
|
||||
|
||||
// OAuth2 token, introspection, revocation, and device authorization
|
||||
// endpoints receive cross-origin POSTs from external clients.
|
||||
csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/token")
|
||||
csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/introspect")
|
||||
csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/revoke")
|
||||
csrf.AddInsecureBypassPattern("POST /connect/v1/oauth2/device")
|
||||
|
||||
csrf.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpserver.RenderJSON(
|
||||
w,
|
||||
|
||||
59
pkg/server/api/authn/oauth2_access_token_middleware.go
Normal file
59
pkg/server/api/authn/oauth2_access_token_middleware.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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 authn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.probo.inc/probo/pkg/bearertoken"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
)
|
||||
|
||||
func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if IdentityFromContext(ctx) != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := svc.OAuth2ServerService.LoadAccessToken(ctx, tokenValue)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := svc.AccountService.GetIdentity(ctx, accessToken.IdentityID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot get identity for oauth2 access token: %w", err))
|
||||
}
|
||||
|
||||
ctx = ContextWithIdentity(ctx, identity)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
31
pkg/server/api/connect/v1/cache.go
Normal file
31
pkg/server/api/connect/v1/cache.go
Normal 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())))
|
||||
}
|
||||
@@ -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"
|
||||
42
pkg/server/api/connect/v1/graphql/oauth2.graphql
Normal file
42
pkg/server/api/connect/v1/graphql/oauth2.graphql
Normal 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
|
||||
}
|
||||
120
pkg/server/api/connect/v1/oauth2_error.go
Normal file
120
pkg/server/api/connect/v1/oauth2_error.go
Normal 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)
|
||||
}
|
||||
547
pkg/server/api/connect/v1/oauth2_handler.go
Normal file
547
pkg/server/api/connect/v1/oauth2_handler.go
Normal 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)
|
||||
}
|
||||
126
pkg/server/api/connect/v1/oauth2_resolvers.go
Normal file
126
pkg/server/api/connect/v1/oauth2_resolvers.go
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
330
pkg/server/api/connect/v1/types/oauth2.go
Normal file
330
pkg/server/api/connect/v1/types/oauth2.go
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ func NewMux(
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
||||
r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret))
|
||||
r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc))
|
||||
r.Use(authn.NewIdentityPresenceMiddleware())
|
||||
r.Use(dataloader.NewMiddleware(proboSvc, iamSvc))
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/file"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/iam/oauth2server"
|
||||
"go.probo.inc/probo/pkg/mailman"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/securecookie"
|
||||
@@ -41,6 +42,7 @@ import (
|
||||
console_web "go.probo.inc/probo/pkg/server/web"
|
||||
"go.probo.inc/probo/pkg/slack"
|
||||
"go.probo.inc/probo/pkg/trust"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -70,7 +72,9 @@ type Server struct {
|
||||
trustWebServer *trust_web.Server
|
||||
router *chi.Mux
|
||||
extraHeaderFields map[string]string
|
||||
baseURL string
|
||||
proboService *probo.Service
|
||||
iamService *iam.Service
|
||||
trustService *trust.Service
|
||||
logger *log.Logger
|
||||
}
|
||||
@@ -119,7 +123,9 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
trustWebServer: trustWebServer,
|
||||
router: router,
|
||||
extraHeaderFields: cfg.ExtraHeaderFields,
|
||||
baseURL: cfg.BaseURL.String(),
|
||||
proboService: cfg.Probo,
|
||||
iamService: cfg.IAM,
|
||||
trustService: cfg.Trust,
|
||||
logger: cfg.Logger,
|
||||
}
|
||||
@@ -130,6 +136,11 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes(baseURL string) {
|
||||
// OIDC Discovery 1.0 §4 and RFC 8414 §3 both require the metadata
|
||||
// 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.Mount("/api", http.StripPrefix("/api", s.apiServer))
|
||||
s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler))
|
||||
|
||||
@@ -153,6 +164,25 @@ 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{
|
||||
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"),
|
||||
}
|
||||
|
||||
metadata := s.iamService.OAuth2ServerService.Metadata(endpoints)
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
httpserver.RenderJSON(w, http.StatusOK, metadata)
|
||||
}
|
||||
|
||||
func (s *Server) handleCustomDomain404(w http.ResponseWriter, r *http.Request) {
|
||||
httpserver.RenderError(w, http.StatusNotFound, errors.New("not found"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user