Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ TINYAUTH_OIDC_CLIENTS_name_CLIENTSECRET=
TINYAUTH_OIDC_CLIENTS_name_CLIENTSECRETFILE=
# List of trusted redirect URIs.
TINYAUTH_OIDC_CLIENTS_name_TRUSTEDREDIRECTURIS=
# Skip the consent screen for this trusted OIDC client.
TINYAUTH_OIDC_CLIENTS_name_TRUSTED=false
# Client name in UI.
TINYAUTH_OIDC_CLIENTS_name_NAME=

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/authorize-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Scope = {

const skipConsentResponseSchema = z.object({
skipConsent: z.boolean(),
redirectUri: z.string().url().optional(),
})

const scopeMapIconProps = {
Expand Down Expand Up @@ -138,6 +139,10 @@ export const AuthorizePage = () => {
const parsed = skipConsentResponseSchema.safeParse(await res.json());
if (!active || !parsed.success || !parsed.data.skipConsent) return;
setAutoAuthorize(true);
if (parsed.data.redirectUri) {
window.location.replace(parsed.data.redirectUri);
return;
}
authorizeMutate();
} catch {
// Fall back to manual consent on any failure (including abort).
Expand Down
136 changes: 98 additions & 38 deletions internal/controller/oidc_controller.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package controller

import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
Expand Down Expand Up @@ -33,6 +34,8 @@ type authorizeErrorParams struct {
json bool
}

var errOIDCClientNotFound = errors.New("oidc client not found")

type OIDCController struct {
log *logger.Logger
oidc *service.OIDCService
Expand Down Expand Up @@ -74,7 +77,8 @@ type SkipConsentRequest struct {
}

type SkipConsentResponse struct {
SkipConsent bool `json:"skipConsent"`
SkipConsent bool `json:"skipConsent"`
RedirectURI string `json:"redirectUri,omitempty"`
}

type AuthorizeScreenParams struct {
Expand Down Expand Up @@ -312,20 +316,68 @@ func (controller *OIDCController) skipConsent(c *gin.Context) {
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.OIDCTicket)

if !ok {
if redirectURI, completed := controller.oidc.GetCompletedAuthorizeRequest(req.OIDCTicket, userContext.GetUsername()); completed {
c.JSON(200, SkipConsentResponse{
SkipConsent: true,
RedirectURI: redirectURI,
})
return
}

c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}

controller.log.App.Debug().Str("client", authorizeReq.ClientID).Str("user", userContext.GetUsername()).Msg("User consented to OIDC")
controller.log.App.Debug().Str("client", authorizeReq.ClientID).Str("user", userContext.GetUsername()).Msg("Checking OIDC consent")

if authorizeReq.Prompt == service.OIDCPromptLogin.String() {
prompts := controller.oidc.GetPrompt(authorizeReq.Prompt)
if slices.Contains(prompts, service.OIDCPromptLogin) {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
if authorizeReq.MaxAge != "" {
maxAge, err := strconv.Atoi(authorizeReq.MaxAge)
if err != nil || time.Unix(userContext.AuthTime, 0).Add(time.Duration(maxAge)*time.Second).Before(time.Now()) {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
}

client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
if ok && client.Trusted {
Comment thread
tilwegener marked this conversation as resolved.
authorizeReq, claimed := controller.oidc.ClaimAuthorizeRequestTicket(req.OIDCTicket)
if !claimed {
if redirectURI, completed := controller.oidc.GetCompletedAuthorizeRequest(req.OIDCTicket, userContext.GetUsername()); completed {
c.JSON(200, SkipConsentResponse{
SkipConsent: true,
RedirectURI: redirectURI,
})
return
}

c.JSON(200, SkipConsentResponse{SkipConsent: false})
return
}

redirectURI, err := controller.completeAuthorization(c.Request.Context(), authorizeReq, userContext, false)
if err != nil {
controller.writeCompleteAuthorizationError(c, authorizeReq, err)
return
}
controller.oidc.StoreCompletedAuthorizeRequest(req.OIDCTicket, userContext.GetUsername(), redirectURI)

c.JSON(200, SkipConsentResponse{
SkipConsent: true,
RedirectURI: redirectURI,
})
return
}

consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), authorizeReq.ClientID)

Expand Down Expand Up @@ -399,7 +451,7 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}

authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.Ticket)
authorizeReq, ok := controller.oidc.ClaimAuthorizeRequestTicket(req.Ticket)

if !ok {
controller.authorizeError(c, authorizeErrorParams{
Expand All @@ -411,38 +463,33 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}

// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
redirectURI, err := controller.completeAuthorization(c.Request.Context(), authorizeReq, userContext, true)
if err != nil {
controller.writeCompleteAuthorizationError(c, authorizeReq, err)
return
}

c.JSON(200, gin.H{
"status": 200,
"redirect_uri": redirectURI,
})
}

func (controller *OIDCController) completeAuthorization(ctx context.Context, authorizeReq *service.AuthorizeRequest, userContext *model.UserContext, persistConsent bool) (string, error) {
// Get the client
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)

if !ok {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("client not found"),
reason: "Client not found",
reasonPublic: "The client is not configured",
json: true,
})
return
return "", errOIDCClientNotFound
}

// Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)

// Before storing the code, delete old session
err = controller.oidc.DeleteOldSession(c, sub)
err := controller.oidc.DeleteOldSession(ctx, sub)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to delete old sessions",
reasonPublic: "Failed to delete old sessions",
callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
})
return
return "", fmt.Errorf("failed to delete old sessions: %w", err)
}

// Create the authorization code
Expand All @@ -451,18 +498,14 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
cu, err := url.Parse(authorizeReq.RedirectURI)

if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to parse redirect URI",
reasonPublic: "Failed to parse redirect URI",
json: true,
})
return
return "", fmt.Errorf("failed to parse redirect URI: %w", err)
}

// Store the consent granted by the user for this client
if _, err := controller.oidc.UpsertOIDCConsent(c, userContext.GetUsername(), authorizeReq.Scope, client.ClientID); err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to store OIDC consent")
if persistConsent {
// Only store consent when the user explicitly approved the request.
if _, err := controller.oidc.UpsertOIDCConsent(ctx, userContext.GetUsername(), authorizeReq.Scope, client.ClientID); err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to store OIDC consent")
}
}

q := cu.Query()
Expand All @@ -475,10 +518,27 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {

cu.RawQuery = q.Encode()

c.JSON(200, gin.H{
"status": 200,
"redirect_uri": cu.String(),
})
return cu.String(), nil
}

func (controller *OIDCController) writeCompleteAuthorizationError(c *gin.Context, authorizeReq *service.AuthorizeRequest, err error) {
params := authorizeErrorParams{
err: err,
reason: "Failed to complete authorization",
reasonPublic: "Failed to complete authorization",
json: true,
}

if errors.Is(err, errOIDCClientNotFound) {
params.reason = "Client not found"
params.reasonPublic = "The client is not configured"
} else {
params.callback = authorizeReq.RedirectURI
params.callbackError = "server_error"
params.state = authorizeReq.State
}

controller.authorizeError(c, params)
}

func (controller *OIDCController) Token(c *gin.Context) {
Expand Down
127 changes: 127 additions & 0 deletions internal/controller/oidc_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -351,6 +353,131 @@ func TestOIDCController(t *testing.T) {
assert.False(t, res.SkipConsent)
},
},
{
description: "Skip consent returns true for a trusted client without prior consent",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "trusted-client-id"))

ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "trusted-client-id",
RedirectURI: "https://trusted.example.com/callback",
})

req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)

assert.Equal(t, http.StatusOK, recorder.Code)

var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.True(t, res.SkipConsent)
assert.Contains(t, res.RedirectURI, "https://trusted.example.com/callback?code=")

_, err := store.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{
Username: "testuser",
ClientID: "trusted-client-id",
})
assert.ErrorIs(t, err, repository.ErrNotFound)
_, ok := oidcService.GetAuthorizeRequestByTicket(ticket)
assert.False(t, ok)
_, ok = oidcService.GetCompletedAuthorizeRequest(ticket, "otheruser")
assert.False(t, ok)

retryRecorder := httptest.NewRecorder()
retryReq := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(retryRecorder, retryReq)

assert.Equal(t, http.StatusOK, retryRecorder.Code)

var retryRes SkipConsentResponse
require.NoError(t, json.Unmarshal(retryRecorder.Body.Bytes(), &retryRes))
assert.True(t, retryRes.SkipConsent)
assert.Equal(t, res.RedirectURI, retryRes.RedirectURI)
},
},
{
description: "Skip consent returns false for a trusted client when prompt includes login",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "trusted-client-id",
RedirectURI: "https://trusted.example.com/callback",
Prompt: "login consent",
})

req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)

assert.Equal(t, http.StatusOK, recorder.Code)

var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.False(t, res.SkipConsent)
assert.Empty(t, res.RedirectURI)
},
},
{
description: "Skip consent returns false for a trusted client when max age is exceeded",
middlewares: []gin.HandlerFunc{
func(c *gin.Context) {
c.Set("context", &model.UserContext{
Authenticated: true,
AuthTime: time.Now().Add(-time.Hour).Unix(),
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "testuser"},
},
})
},
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
Scope: "openid profile",
ResponseType: "code",
ClientID: "trusted-client-id",
RedirectURI: "https://trusted.example.com/callback",
MaxAge: "60",
})

req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket="+url.QueryEscape(ticket), nil)
router.ServeHTTP(recorder, req)

assert.Equal(t, http.StatusOK, recorder.Code)

var res SkipConsentResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
assert.False(t, res.SkipConsent)
assert.Empty(t, res.RedirectURI)
_, ok := oidcService.GetAuthorizeRequestByTicket(ticket)
assert.True(t, ok)
},
},
{
description: "Authorize request ticket can only be claimed once",
run: func(t *testing.T, _ *gin.Engine, _ *httptest.ResponseRecorder) {
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{ClientID: "trusted-client-id"})
var claims atomic.Int32
var wg sync.WaitGroup

for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok := oidcService.ClaimAuthorizeRequestTicket(ticket); ok {
claims.Add(1)
}
}()
}

wg.Wait()
assert.Equal(t, int32(1), claims.Load())
},
},
{
description: "Skip consent returns false when a new scope is requested",
middlewares: []gin.HandlerFunc{authedUser},
Expand Down
1 change: 1 addition & 0 deletions internal/model/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ type OIDCClientConfig struct {
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"`
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"`
TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"`
Trusted bool `description:"Skip the consent screen for this trusted OIDC client." yaml:"trusted,omitempty"`
Name string `description:"Client name in UI." yaml:"name,omitempty"`
}

Expand Down
Loading