feat: add OAuth APIs with scoped permissions (#1542)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2026-07-06 12:25:02 -07:00
committed by GitHub
co-authored by Alessandro Segala
parent 0b2706a488
commit 09d196f7c5
68 changed files with 3945 additions and 157 deletions
+34 -6
View File
@@ -2,6 +2,7 @@ package oidc
import (
"log/slog"
"slices"
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
@@ -10,12 +11,14 @@ import (
type tokenHandler struct {
provider fosite.OAuth2Provider
claimsService *ClaimsService
apiAccess APIAccessProvider
}
func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *tokenHandler {
func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService, apiAccess APIAccessProvider) *tokenHandler {
return &tokenHandler{
provider: provider,
claimsService: claimsService,
apiAccess: apiAccess,
}
}
@@ -42,17 +45,41 @@ func (h *tokenHandler) token(c *gin.Context) {
if client, ok := accessRequest.GetClient().(Client); ok {
// Re-validate the resource owner on every user-bound grant.
if err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client); err != nil {
err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client)
if err != nil {
slog.WarnContext(ctx, "Rejected token request: user no longer allowed to access client", "error", err.Error())
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
return
}
// Bind every issued JWT access token to the requesting client so it always carries an aud claim.
accessRequest.GrantAudience(client.GetID())
// The client credentials grant has no authorize step so the RFC 8707 resource is resolved here to stamp the API audience and limit the granted scope to what the client is allowed for that API
// It resolves against the client-subject grants: a permission delegated by users does not let the client act as itself
// The other grants had their audience and scope resolved at authorize or device time and restored from storage, so they must be left untouched
if accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
resource, err := accessRequest.GetResource()
if err != nil {
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
return
}
audience, grantedScopes, err := resolveResource(ctx, h.apiAccess, client.GetID(), resource, accessRequest.GetRequestedScopes(), SubjectTypeClient)
if err != nil {
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
return
}
// A client credentials token has no resource owner, so it must never carry identity scopes such as openid or profile
// Dropping them keeps machine tokens out of the userinfo endpoint, which is gated on the openid scope
grantedScopes = slices.DeleteFunc(grantedScopes, isStandardScope)
accessReq, ok := accessRequest.(*fosite.AccessRequest)
if ok {
accessReq.GrantedScope = grantedScopes
accessReq.GrantedAudience = nil
}
accessRequest.GrantResourceIndicator(audience, grantedScopes)
}
}
if err := h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes()); err != nil {
err = h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes())
if err != nil {
slog.ErrorContext(ctx, "Failed to apply ID token claims", "error", err)
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
return
@@ -61,7 +88,8 @@ func (h *tokenHandler) token(c *gin.Context) {
// The client credentials grant has no resource owner, so no subject is ever set. Assign a
// stable synthetic subject so the issued JWT access token still carries a subclaim.
if requestSession.Subject == "" {
if client, ok := accessRequest.GetClient().(Client); ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
client, ok := accessRequest.GetClient().(Client)
if ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
requestSession.Subject = "client-" + client.GetID()
}
}