mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-08-29 17:43:33 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe21a7e38 | ||
|
|
0da39c93b0 | ||
|
|
766a7287e3 |
@@ -1,5 +1,5 @@
|
|||||||
import { useUserContext } from "@/context/user-context";
|
import { useUserContext } from "@/context/user-context";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import { Navigate, useNavigate } from "react-router";
|
import { Navigate, useNavigate } from "react-router";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
searchParamsFromObject,
|
searchParamsFromObject,
|
||||||
useScreenParams,
|
useScreenParams,
|
||||||
} from "@/lib/hooks/screen-params";
|
} from "@/lib/hooks/screen-params";
|
||||||
import { useEffect } from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
type Scope = {
|
type Scope = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +35,10 @@ type Scope = {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skipConsentResponseSchema = z.object({
|
||||||
|
skipConsent: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
const scopeMapIconProps = {
|
const scopeMapIconProps = {
|
||||||
className: "stroke-muted-foreground stroke-[1.75] h-4",
|
className: "stroke-muted-foreground stroke-[1.75] h-4",
|
||||||
};
|
};
|
||||||
@@ -96,14 +101,8 @@ export const AuthorizePage = () => {
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
})();
|
})();
|
||||||
|
const [autoAuthorize, setAutoAuthorize] = useState(false);
|
||||||
// TODO: maybe a better way to do this
|
const [skipConsentChecked, setSkipConsentChecked] = useState(false);
|
||||||
const shouldAutoAuthorize =
|
|
||||||
auth.authenticated &&
|
|
||||||
isOidc &&
|
|
||||||
screenParams.oidc_ticket !== undefined &&
|
|
||||||
screenParams.oidc_scope !== undefined &&
|
|
||||||
screenParams.oidc_prompt === "none";
|
|
||||||
|
|
||||||
const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({
|
const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -126,10 +125,34 @@ export const AuthorizePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldAutoAuthorize) {
|
let active = true;
|
||||||
authorizeMutate();
|
const controller = new AbortController();
|
||||||
}
|
|
||||||
}, [shouldAutoAuthorize, authorizeMutate]);
|
const checkSkipConsent = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/oidc/skip-consent?oidc_ticket=${encodeURIComponent( screenParams.oidc_ticket ?? "")}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const parsed = skipConsentResponseSchema.safeParse(await res.json());
|
||||||
|
if (!active || !parsed.success || !parsed.data.skipConsent) return;
|
||||||
|
setAutoAuthorize(true);
|
||||||
|
authorizeMutate();
|
||||||
|
} catch {
|
||||||
|
// Fall back to manual consent on any failure (including abort).
|
||||||
|
} finally {
|
||||||
|
if (active) setSkipConsentChecked(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkSkipConsent();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [authorizeMutate, screenParams.oidc_ticket]);
|
||||||
|
|
||||||
if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) {
|
if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) {
|
||||||
return (
|
return (
|
||||||
@@ -190,13 +213,13 @@ export const AuthorizePage = () => {
|
|||||||
<CardFooter className="flex flex-col items-stretch gap-3">
|
<CardFooter className="flex flex-col items-stretch gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => authorizeMutate()}
|
onClick={() => authorizeMutate()}
|
||||||
loading={authorizePending || shouldAutoAuthorize}
|
loading={authorizePending || autoAuthorize || !skipConsentChecked}
|
||||||
>
|
>
|
||||||
{t("authorizeTitle")}
|
{t("authorizeTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/logout${compiledParams}`)}
|
onClick={() => navigate(`/logout${compiledParams}`)}
|
||||||
disabled={authorizePending || shouldAutoAuthorize}
|
disabled={authorizePending || autoAuthorize}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{t("cancelTitle")}
|
{t("cancelTitle")}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
plugins: [react(), tailwindcss(), visualizer()],
|
plugins: [react(), tailwindcss(), visualizer()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(import.meta.dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ type RedirectQuery struct {
|
|||||||
RedirectURI string `url:"redirect_uri"`
|
RedirectURI string `url:"redirect_uri"`
|
||||||
LoginFor FrontendLoginFor `url:"login_for"`
|
LoginFor FrontendLoginFor `url:"login_for"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SimpleResponse struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,15 +62,19 @@ type ErrorScreen struct {
|
|||||||
Error string `url:"error"`
|
Error string `url:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientRequest struct {
|
|
||||||
ClientID string `uri:"id" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClientCredentials struct {
|
type ClientCredentials struct {
|
||||||
ClientID string
|
ClientID string
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkipConsentRequest struct {
|
||||||
|
OIDCTicket string `form:"oidc_ticket" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkipConsentResponse struct {
|
||||||
|
SkipConsent bool `json:"skipConsent"`
|
||||||
|
}
|
||||||
|
|
||||||
type AuthorizeScreenParams struct {
|
type AuthorizeScreenParams struct {
|
||||||
LoginFor FrontendLoginFor `url:"login_for"`
|
LoginFor FrontendLoginFor `url:"login_for"`
|
||||||
OIDCTicket string `url:"oidc_ticket"`
|
OIDCTicket string `url:"oidc_ticket"`
|
||||||
@@ -105,6 +109,7 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {
|
|||||||
|
|
||||||
oidcGroup := i.RouterGroup.Group("/oidc")
|
oidcGroup := i.RouterGroup.Group("/oidc")
|
||||||
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
|
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
|
||||||
|
oidcGroup.GET("/skip-consent", controller.skipConsent)
|
||||||
oidcGroup.POST("/token", controller.Token)
|
oidcGroup.POST("/token", controller.Token)
|
||||||
oidcGroup.GET("/userinfo", controller.Userinfo)
|
oidcGroup.GET("/userinfo", controller.Userinfo)
|
||||||
oidcGroup.POST("/userinfo", controller.Userinfo)
|
oidcGroup.POST("/userinfo", controller.Userinfo)
|
||||||
@@ -242,16 +247,6 @@ func (controller *OIDCController) authorize(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext != nil && userContext.Authenticated && values.OIDCPrompt != service.OIDCPromptLogin {
|
|
||||||
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), req.ClientID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
|
|
||||||
} else if consent != nil && scopesGranted(consent.Scope, req.Scope) {
|
|
||||||
values.OIDCPrompt = service.OIDCPromptNone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queries, err := query.Values(values)
|
queries, err := query.Values(values)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -270,6 +265,90 @@ func (controller *OIDCController) authorize(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, redirectUrl)
|
c.Redirect(http.StatusFound, redirectUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (controller *OIDCController) skipConsent(c *gin.Context) {
|
||||||
|
c.Header("cache-control", "no-store")
|
||||||
|
c.Header("pragma", "no-cache")
|
||||||
|
|
||||||
|
if controller.oidc == nil {
|
||||||
|
c.JSON(500, SimpleResponse{
|
||||||
|
Status: 500,
|
||||||
|
Message: "OIDC not configured",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userContext, err := new(model.UserContext).NewFromGin(c)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, model.ErrUserContextNotFound) {
|
||||||
|
controller.log.App.Warn().Err(err).Msg("Failed to get user context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || !userContext.Authenticated {
|
||||||
|
c.JSON(401, SimpleResponse{
|
||||||
|
Status: 401,
|
||||||
|
Message: "User not logged in",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SkipConsentRequest
|
||||||
|
|
||||||
|
err = c.BindQuery(&req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, SimpleResponse{
|
||||||
|
Status: 400,
|
||||||
|
Message: "Bad request",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.log.App.Debug().Interface("req", req).Msg("Received skip consent request")
|
||||||
|
|
||||||
|
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.OIDCTicket)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
c.JSON(200, SkipConsentResponse{
|
||||||
|
SkipConsent: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.log.App.Debug().Str("client", authorizeReq.ClientID).Str("user", userContext.GetUsername()).Msg("User consented to OIDC")
|
||||||
|
|
||||||
|
if authorizeReq.Prompt == service.OIDCPromptLogin.String() {
|
||||||
|
c.JSON(200, SkipConsentResponse{
|
||||||
|
SkipConsent: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), authorizeReq.ClientID)
|
||||||
|
|
||||||
|
if err != nil || consent == nil {
|
||||||
|
if err != nil {
|
||||||
|
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
|
||||||
|
}
|
||||||
|
c.JSON(200, SkipConsentResponse{
|
||||||
|
SkipConsent: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !scopesGranted(consent.Scope, authorizeReq.Scope) {
|
||||||
|
c.JSON(200, SkipConsentResponse{
|
||||||
|
SkipConsent: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, SkipConsentResponse{
|
||||||
|
SkipConsent: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The actual **internal** endpoint that actually creates the code and session.
|
// The actual **internal** endpoint that actually creates the code and session.
|
||||||
// It is called by the frontend after the user has logged in and given consent.
|
// It is called by the frontend after the user has logged in and given consent.
|
||||||
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
||||||
@@ -330,6 +409,9 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We no longer need the ticket
|
||||||
|
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
|
||||||
|
|
||||||
// Get the client
|
// Get the client
|
||||||
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
|
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
|
||||||
|
|
||||||
@@ -343,9 +425,6 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We no longer need the ticket
|
|
||||||
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
|
|
||||||
|
|
||||||
// Create the sub to find and delete old sessions
|
// Create the sub to find and delete old sessions
|
||||||
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
|
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Authorize skips the consent screen when all requested scopes were already granted",
|
description: "Authorize does not skip the consent screen even when consent was already granted",
|
||||||
middlewares: []gin.HandlerFunc{authedUser},
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||||
@@ -189,77 +189,9 @@ func TestOIDCController(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
// The consent skip check now happens after auth via the
|
||||||
location := recorder.Header().Get("Location")
|
// skip-consent endpoint, so authorize should never set
|
||||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
// prompt=none on its own.
|
||||||
assert.Contains(t, location, "oidc_prompt=none")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Authorize shows the consent screen when a new scope is requested",
|
|
||||||
middlewares: []gin.HandlerFunc{authedUser},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
|
||||||
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
|
||||||
Username: "testuser", ClientID: "some-client-id",
|
|
||||||
Scope: "openid", CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("scope", "openid profile")
|
|
||||||
q.Set("response_type", "code")
|
|
||||||
q.Set("client_id", "some-client-id")
|
|
||||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
|
||||||
location := recorder.Header().Get("Location")
|
|
||||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
|
||||||
assert.NotContains(t, location, "oidc_prompt=none")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Authorize skips the consent screen for a subset of already granted scopes",
|
|
||||||
middlewares: []gin.HandlerFunc{authedUser},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
|
||||||
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
|
||||||
Username: "testuser", ClientID: "some-client-id",
|
|
||||||
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("scope", "openid profile")
|
|
||||||
q.Set("response_type", "code")
|
|
||||||
q.Set("client_id", "some-client-id")
|
|
||||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
|
||||||
location := recorder.Header().Get("Location")
|
|
||||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
|
||||||
assert.Contains(t, location, "oidc_prompt=none")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Authorize shows the consent screen when no consent was granted yet",
|
|
||||||
middlewares: []gin.HandlerFunc{authedUser},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
|
||||||
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
|
|
||||||
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("scope", "openid profile")
|
|
||||||
q.Set("response_type", "code")
|
|
||||||
q.Set("client_id", "some-client-id")
|
|
||||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||||
location := recorder.Header().Get("Location")
|
location := recorder.Header().Get("Location")
|
||||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
||||||
@@ -304,6 +236,232 @@ func TestOIDCController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- skip-consent ---
|
||||||
|
{
|
||||||
|
description: "Skip consent returns 500 when OIDC is not configured",
|
||||||
|
oidcDisabled: true,
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=some-ticket", nil)
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns 401 when the user context is missing",
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=some-ticket", nil)
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns 401 when the user is not authenticated",
|
||||||
|
middlewares: []gin.HandlerFunc{
|
||||||
|
func(c *gin.Context) {
|
||||||
|
c.Set("context", &model.UserContext{
|
||||||
|
Authenticated: false,
|
||||||
|
Provider: model.ProviderLocal,
|
||||||
|
Local: &model.LocalContext{
|
||||||
|
BaseContext: model.BaseContext{Username: "testuser"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=some-ticket", nil)
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns 400 when the ticket is missing",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/oidc/skip-consent", nil)
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns false when the ticket is unknown",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/oidc/skip-consent?oidc_ticket=nonexistent-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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns true when the authorize request has prompt none",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||||
|
Username: "testuser", ClientID: "some-client-id",
|
||||||
|
Scope: "openid profile", CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid profile",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
Prompt: "none",
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns false when no consent was granted yet",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
|
||||||
|
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid profile",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.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.False(t, res.SkipConsent)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns false when a new scope is requested",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||||
|
Username: "testuser", ClientID: "some-client-id",
|
||||||
|
Scope: "openid", CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid profile",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.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.False(t, res.SkipConsent)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns true when all requested scopes were already granted",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||||
|
Username: "testuser", ClientID: "some-client-id",
|
||||||
|
Scope: "openid profile", CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid profile",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns true for a subset of already granted scopes",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||||
|
Username: "testuser", ClientID: "some-client-id",
|
||||||
|
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid profile",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Skip consent returns false when the consent belongs to another user",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
|
||||||
|
|
||||||
|
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||||
|
Username: "otheruser", ClientID: "some-client-id",
|
||||||
|
Scope: "openid profile", CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid profile",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.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.False(t, res.SkipConsent)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// --- authorize-complete ---
|
// --- authorize-complete ---
|
||||||
{
|
{
|
||||||
description: "Should fail if oidc is disabled",
|
description: "Should fail if oidc is disabled",
|
||||||
@@ -423,6 +581,60 @@ func TestOIDCController(t *testing.T) {
|
|||||||
assert.Contains(t, redirectURI, "state=state-123")
|
assert.Contains(t, redirectURI, "state=state-123")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: "Authorize complete deletes the ticket on success",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
})
|
||||||
|
|
||||||
|
body, err := json.Marshal(AuthorizeCompleteRequest{Ticket: ticket})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/oidc/authorize-complete", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
_, ok := oidcService.GetAuthorizeRequestByTicket(ticket)
|
||||||
|
assert.False(t, ok)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Authorize complete deletes the ticket even when the client is unknown",
|
||||||
|
middlewares: []gin.HandlerFunc{authedUser},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
ticket := oidcService.CreateAuthorizeRequestTicket(service.AuthorizeRequest{
|
||||||
|
Scope: "openid",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "unknown-client",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
})
|
||||||
|
|
||||||
|
body, err := json.Marshal(AuthorizeCompleteRequest{Ticket: ticket})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/oidc/authorize-complete", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
var res map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
|
||||||
|
redirectURI, ok := res["redirect_uri"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, redirectURI, oidcService.GetIssuer()+"/error")
|
||||||
|
|
||||||
|
_, ok = oidcService.GetAuthorizeRequestByTicket(ticket)
|
||||||
|
assert.False(t, ok)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// --- token ---
|
// --- token ---
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ const (
|
|||||||
OIDCPromptNone OIDCPrompt = "none"
|
OIDCPromptNone OIDCPrompt = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (p OIDCPrompt) String() string {
|
||||||
|
switch p {
|
||||||
|
case OIDCPromptLogin:
|
||||||
|
return "login"
|
||||||
|
case OIDCPromptNone:
|
||||||
|
return "none"
|
||||||
|
default:
|
||||||
|
return "login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)}
|
var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)}
|
||||||
|
|
||||||
// This is not spec-compliant, the ID token SHOULD NOT contain user info claims but,
|
// This is not spec-compliant, the ID token SHOULD NOT contain user info claims but,
|
||||||
|
|||||||
Reference in New Issue
Block a user