mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-04-07 22:37:55 +00:00
Compare commits
8 Commits
dependabot
...
feat/pkce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def9e5aaaa | ||
|
|
6ffb52a5cd | ||
|
|
668348655f | ||
|
|
482b3c6b57 | ||
|
|
e451b3d62f | ||
|
|
5bada13919 | ||
|
|
9a6676b054 | ||
|
|
431cd33053 |
2
.github/workflows/sponsors.yml
vendored
2
.github/workflows/sponsors.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="64px" alt="User avatar: {{{ login }}}" /></a> '
|
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="64px" alt="User avatar: {{{ login }}}" /></a> '
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v8
|
uses: peter-evans/create-pull-request@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
commit-message: |
|
commit-message: |
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
|
|||||||
|
|
||||||
A big thank you to the following people for providing me with more coffee:
|
A big thank you to the following people for providing me with more coffee:
|
||||||
|
|
||||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <!-- sponsors -->
|
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <!-- sponsors -->
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export type OIDCValues = {
|
|||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
state: string;
|
state: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
|
code_challenge: string;
|
||||||
|
code_challenge_method: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IuseOIDCParams {
|
interface IuseOIDCParams {
|
||||||
@@ -14,7 +16,12 @@ interface IuseOIDCParams {
|
|||||||
missingParams: string[];
|
missingParams: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionalParams: string[] = ["state", "nonce"];
|
const optionalParams: string[] = [
|
||||||
|
"state",
|
||||||
|
"nonce",
|
||||||
|
"code_challenge",
|
||||||
|
"code_challenge_method",
|
||||||
|
];
|
||||||
|
|
||||||
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
|
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
|
||||||
let compiled: string = "";
|
let compiled: string = "";
|
||||||
@@ -28,6 +35,8 @@ export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
|
|||||||
redirect_uri: params.get("redirect_uri") ?? "",
|
redirect_uri: params.get("redirect_uri") ?? "",
|
||||||
state: params.get("state") ?? "",
|
state: params.get("state") ?? "",
|
||||||
nonce: params.get("nonce") ?? "",
|
nonce: params.get("nonce") ?? "",
|
||||||
|
code_challenge: params.get("code_challenge") ?? "",
|
||||||
|
code_challenge_method: params.get("code_challenge_method") ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of Object.keys(values)) {
|
for (const key of Object.keys(values)) {
|
||||||
|
|||||||
1
internal/assets/migrations/000007_oidc_pkce.down.sql
Normal file
1
internal/assets/migrations/000007_oidc_pkce.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "oidc_codes" DROP COLUMN "code_challenge";
|
||||||
1
internal/assets/migrations/000007_oidc_pkce.up.sql
Normal file
1
internal/assets/migrations/000007_oidc_pkce.up.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "oidc_codes" ADD COLUMN "code_challenge" TEXT DEFAULT "";
|
||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
"github.com/steveiliop56/tinyauth/internal/config"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestContextController(t *testing.T) {
|
func TestContextController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
controllerConfig := controller.ContextControllerConfig{
|
controllerConfig := controller.ContextControllerConfig{
|
||||||
Providers: []controller.Provider{
|
Providers: []controller.Provider{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthController(t *testing.T) {
|
func TestHealthController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
description string
|
description string
|
||||||
path string
|
path string
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type TokenRequest struct {
|
|||||||
RefreshToken string `form:"refresh_token" url:"refresh_token"`
|
RefreshToken string `form:"refresh_token" url:"refresh_token"`
|
||||||
ClientSecret string `form:"client_secret" url:"client_secret"`
|
ClientSecret string `form:"client_secret" url:"client_secret"`
|
||||||
ClientID string `form:"client_id" url:"client_id"`
|
ClientID string `form:"client_id" url:"client_id"`
|
||||||
|
CodeVerifier string `form:"code_verifier" url:"code_verifier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallbackError struct {
|
type CallbackError struct {
|
||||||
@@ -308,6 +309,16 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
tlog.App.Warn().Msg("PKCE validation failed")
|
||||||
|
c.JSON(400, gin.H{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
|
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package controller_test
|
package controller_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,11 +17,13 @@ import (
|
|||||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
"github.com/steveiliop56/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/steveiliop56/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOIDCController(t *testing.T) {
|
func TestOIDCController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
oidcServiceCfg := service.OIDCServiceConfig{
|
oidcServiceCfg := service.OIDCServiceConfig{
|
||||||
@@ -431,6 +435,227 @@ func TestOIDCController(t *testing.T) {
|
|||||||
assert.False(t, ok, "Did not expect email claim in userinfo response")
|
assert.False(t, ok, "Did not expect email claim in userinfo response")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: "Ensure plain PKCE succeeds",
|
||||||
|
middlewares: []gin.HandlerFunc{
|
||||||
|
simpleCtx,
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
reqBody := service.AuthorizeRequest{
|
||||||
|
Scope: "openid",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
State: "some-state",
|
||||||
|
Nonce: "some-nonce",
|
||||||
|
CodeChallenge: "some-challenge",
|
||||||
|
// Not setting a code challenge method should default to "plain"
|
||||||
|
CodeChallengeMethod: "",
|
||||||
|
}
|
||||||
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
|
||||||
|
var res map[string]any
|
||||||
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
redirectURI := res["redirect_uri"].(string)
|
||||||
|
url, err := url.Parse(redirectURI)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
queryParams := url.Query()
|
||||||
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
|
|
||||||
|
code := queryParams.Get("code")
|
||||||
|
assert.NotEmpty(t, code)
|
||||||
|
|
||||||
|
// Now exchange the code for a token
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
tokenReqBody := controller.TokenRequest{
|
||||||
|
GrantType: "authorization_code",
|
||||||
|
Code: code,
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
CodeVerifier: "some-challenge",
|
||||||
|
}
|
||||||
|
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth("some-client-id", "some-client-secret")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Ensure S256 PKCE succeeds",
|
||||||
|
middlewares: []gin.HandlerFunc{
|
||||||
|
simpleCtx,
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte("some-challenge"))
|
||||||
|
codeChallenge := hasher.Sum(nil)
|
||||||
|
codeChallengeEncoded := base64.RawURLEncoding.EncodeToString(codeChallenge)
|
||||||
|
reqBody := service.AuthorizeRequest{
|
||||||
|
Scope: "openid",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
State: "some-state",
|
||||||
|
Nonce: "some-nonce",
|
||||||
|
CodeChallenge: codeChallengeEncoded,
|
||||||
|
CodeChallengeMethod: "S256",
|
||||||
|
}
|
||||||
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
|
||||||
|
var res map[string]any
|
||||||
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
redirectURI := res["redirect_uri"].(string)
|
||||||
|
url, err := url.Parse(redirectURI)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
queryParams := url.Query()
|
||||||
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
|
|
||||||
|
code := queryParams.Get("code")
|
||||||
|
assert.NotEmpty(t, code)
|
||||||
|
|
||||||
|
// Now exchange the code for a token
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
tokenReqBody := controller.TokenRequest{
|
||||||
|
GrantType: "authorization_code",
|
||||||
|
Code: code,
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
CodeVerifier: "some-challenge",
|
||||||
|
}
|
||||||
|
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth("some-client-id", "some-client-secret")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Ensure request with invalid PKCE fails",
|
||||||
|
middlewares: []gin.HandlerFunc{
|
||||||
|
simpleCtx,
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte("some-challenge"))
|
||||||
|
codeChallenge := hasher.Sum(nil)
|
||||||
|
codeChallengeEncoded := base64.RawURLEncoding.EncodeToString(codeChallenge)
|
||||||
|
reqBody := service.AuthorizeRequest{
|
||||||
|
Scope: "openid",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
State: "some-state",
|
||||||
|
Nonce: "some-nonce",
|
||||||
|
CodeChallenge: codeChallengeEncoded,
|
||||||
|
CodeChallengeMethod: "S256",
|
||||||
|
}
|
||||||
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
|
||||||
|
var res map[string]any
|
||||||
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
redirectURI := res["redirect_uri"].(string)
|
||||||
|
url, err := url.Parse(redirectURI)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
queryParams := url.Query()
|
||||||
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
|
|
||||||
|
code := queryParams.Get("code")
|
||||||
|
assert.NotEmpty(t, code)
|
||||||
|
|
||||||
|
// Now exchange the code for a token
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
tokenReqBody := controller.TokenRequest{
|
||||||
|
GrantType: "authorization_code",
|
||||||
|
Code: code,
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
CodeVerifier: "some-challenge-1",
|
||||||
|
}
|
||||||
|
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth("some-client-id", "some-client-secret")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
assert.Equal(t, 400, recorder.Code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Ensure request with invalid challenge method fails",
|
||||||
|
middlewares: []gin.HandlerFunc{
|
||||||
|
simpleCtx,
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte("some-challenge"))
|
||||||
|
codeChallenge := hasher.Sum(nil)
|
||||||
|
codeChallengeEncoded := base64.RawURLEncoding.EncodeToString(codeChallenge)
|
||||||
|
reqBody := service.AuthorizeRequest{
|
||||||
|
Scope: "openid",
|
||||||
|
ResponseType: "code",
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
RedirectURI: "https://test.example.com/callback",
|
||||||
|
State: "some-state",
|
||||||
|
Nonce: "some-nonce",
|
||||||
|
CodeChallenge: codeChallengeEncoded,
|
||||||
|
CodeChallengeMethod: "foo",
|
||||||
|
}
|
||||||
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
|
||||||
|
var res map[string]any
|
||||||
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
redirectURI := res["redirect_uri"].(string)
|
||||||
|
url, err := url.Parse(redirectURI)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
queryParams := url.Query()
|
||||||
|
error := queryParams.Get("error")
|
||||||
|
assert.NotEmpty(t, error)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestProxyController(t *testing.T) {
|
func TestProxyController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
authServiceCfg := service.AuthServiceConfig{
|
authServiceCfg := service.AuthServiceConfig{
|
||||||
@@ -390,8 +391,6 @@ func TestProxyController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tlog.NewSimpleLogger().Init()
|
|
||||||
|
|
||||||
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResourcesController(t *testing.T) {
|
func TestResourcesController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
resourcesControllerCfg := controller.ResourcesControllerConfig{
|
resourcesControllerCfg := controller.ResourcesControllerConfig{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestUserController(t *testing.T) {
|
func TestUserController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
authServiceCfg := service.AuthServiceConfig{
|
authServiceCfg := service.AuthServiceConfig{
|
||||||
@@ -274,8 +275,6 @@ func TestUserController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tlog.NewSimpleLogger().Init()
|
|
||||||
|
|
||||||
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
"github.com/steveiliop56/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/steveiliop56/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWellKnownController(t *testing.T) {
|
func TestWellKnownController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
oidcServiceCfg := service.OIDCServiceConfig{
|
oidcServiceCfg := service.OIDCServiceConfig{
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
type OidcCode struct {
|
type OidcCode struct {
|
||||||
Sub string
|
Sub string
|
||||||
CodeHash string
|
CodeHash string
|
||||||
Scope string
|
Scope string
|
||||||
RedirectURI string
|
RedirectURI string
|
||||||
ClientID string
|
ClientID string
|
||||||
ExpiresAt int64
|
ExpiresAt int64
|
||||||
Nonce string
|
Nonce string
|
||||||
|
CodeChallenge string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcToken struct {
|
type OidcToken struct {
|
||||||
|
|||||||
@@ -17,21 +17,23 @@ INSERT INTO "oidc_codes" (
|
|||||||
"redirect_uri",
|
"redirect_uri",
|
||||||
"client_id",
|
"client_id",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"nonce"
|
"nonce",
|
||||||
|
"code_challenge"
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce
|
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateOidcCodeParams struct {
|
type CreateOidcCodeParams struct {
|
||||||
Sub string
|
Sub string
|
||||||
CodeHash string
|
CodeHash string
|
||||||
Scope string
|
Scope string
|
||||||
RedirectURI string
|
RedirectURI string
|
||||||
ClientID string
|
ClientID string
|
||||||
ExpiresAt int64
|
ExpiresAt int64
|
||||||
Nonce string
|
Nonce string
|
||||||
|
CodeChallenge string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {
|
func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {
|
||||||
@@ -43,6 +45,7 @@ func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams)
|
|||||||
arg.ClientID,
|
arg.ClientID,
|
||||||
arg.ExpiresAt,
|
arg.ExpiresAt,
|
||||||
arg.Nonce,
|
arg.Nonce,
|
||||||
|
arg.CodeChallenge,
|
||||||
)
|
)
|
||||||
var i OidcCode
|
var i OidcCode
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -53,6 +56,7 @@ func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams)
|
|||||||
&i.ClientID,
|
&i.ClientID,
|
||||||
&i.ExpiresAt,
|
&i.ExpiresAt,
|
||||||
&i.Nonce,
|
&i.Nonce,
|
||||||
|
&i.CodeChallenge,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -156,7 +160,7 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
|||||||
const deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many
|
const deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many
|
||||||
DELETE FROM "oidc_codes"
|
DELETE FROM "oidc_codes"
|
||||||
WHERE "expires_at" < ?
|
WHERE "expires_at" < ?
|
||||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce
|
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {
|
func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {
|
||||||
@@ -176,6 +180,7 @@ func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) (
|
|||||||
&i.ClientID,
|
&i.ClientID,
|
||||||
&i.ExpiresAt,
|
&i.ExpiresAt,
|
||||||
&i.Nonce,
|
&i.Nonce,
|
||||||
|
&i.CodeChallenge,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -286,7 +291,7 @@ func (q *Queries) DeleteOidcUserInfo(ctx context.Context, sub string) error {
|
|||||||
const getOidcCode = `-- name: GetOidcCode :one
|
const getOidcCode = `-- name: GetOidcCode :one
|
||||||
DELETE FROM "oidc_codes"
|
DELETE FROM "oidc_codes"
|
||||||
WHERE "code_hash" = ?
|
WHERE "code_hash" = ?
|
||||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce
|
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {
|
func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {
|
||||||
@@ -300,6 +305,7 @@ func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, e
|
|||||||
&i.ClientID,
|
&i.ClientID,
|
||||||
&i.ExpiresAt,
|
&i.ExpiresAt,
|
||||||
&i.Nonce,
|
&i.Nonce,
|
||||||
|
&i.CodeChallenge,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -307,7 +313,7 @@ func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, e
|
|||||||
const getOidcCodeBySub = `-- name: GetOidcCodeBySub :one
|
const getOidcCodeBySub = `-- name: GetOidcCodeBySub :one
|
||||||
DELETE FROM "oidc_codes"
|
DELETE FROM "oidc_codes"
|
||||||
WHERE "sub" = ?
|
WHERE "sub" = ?
|
||||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce
|
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {
|
func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {
|
||||||
@@ -321,12 +327,13 @@ func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, e
|
|||||||
&i.ClientID,
|
&i.ClientID,
|
||||||
&i.ExpiresAt,
|
&i.ExpiresAt,
|
||||||
&i.Nonce,
|
&i.Nonce,
|
||||||
|
&i.CodeChallenge,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one
|
const getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one
|
||||||
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce FROM "oidc_codes"
|
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
|
||||||
WHERE "sub" = ?
|
WHERE "sub" = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -341,12 +348,13 @@ func (q *Queries) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcC
|
|||||||
&i.ClientID,
|
&i.ClientID,
|
||||||
&i.ExpiresAt,
|
&i.ExpiresAt,
|
||||||
&i.Nonce,
|
&i.Nonce,
|
||||||
|
&i.CodeChallenge,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one
|
const getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one
|
||||||
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce FROM "oidc_codes"
|
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
|
||||||
WHERE "code_hash" = ?
|
WHERE "code_hash" = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -361,6 +369,7 @@ func (q *Queries) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcC
|
|||||||
&i.ClientID,
|
&i.ClientID,
|
||||||
&i.ExpiresAt,
|
&i.ExpiresAt,
|
||||||
&i.Nonce,
|
&i.Nonce,
|
||||||
|
&i.CodeChallenge,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,14 @@ type TokenResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeRequest struct {
|
type AuthorizeRequest struct {
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
ResponseType string `json:"response_type" binding:"required"`
|
ResponseType string `json:"response_type" binding:"required"`
|
||||||
ClientID string `json:"client_id" binding:"required"`
|
ClientID string `json:"client_id" binding:"required"`
|
||||||
RedirectURI string `json:"redirect_uri" binding:"required"`
|
RedirectURI string `json:"redirect_uri" binding:"required"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
|
CodeChallenge string `json:"code_challenge"`
|
||||||
|
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCServiceConfig struct {
|
type OIDCServiceConfig struct {
|
||||||
@@ -293,6 +295,13 @@ func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error
|
|||||||
return errors.New("invalid_request_uri")
|
return errors.New("invalid_request_uri")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PKCE code challenge method if set
|
||||||
|
if req.CodeChallenge != "" && req.CodeChallengeMethod != "" {
|
||||||
|
if req.CodeChallengeMethod != "S256" && req.CodeChallengeMethod != "plain" {
|
||||||
|
return errors.New("invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,8 +315,7 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
|||||||
// Fixed 10 minutes
|
// Fixed 10 minutes
|
||||||
expiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()
|
expiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()
|
||||||
|
|
||||||
// Insert the code into the database
|
entry := repository.CreateOidcCodeParams{
|
||||||
_, err := service.queries.CreateOidcCode(c, repository.CreateOidcCodeParams{
|
|
||||||
Sub: sub,
|
Sub: sub,
|
||||||
CodeHash: service.Hash(code),
|
CodeHash: service.Hash(code),
|
||||||
// Here it's safe to split and trust the output since, we validated the scopes before
|
// Here it's safe to split and trust the output since, we validated the scopes before
|
||||||
@@ -316,7 +324,19 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
|||||||
ClientID: req.ClientID,
|
ClientID: req.ClientID,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
Nonce: req.Nonce,
|
Nonce: req.Nonce,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if req.CodeChallenge != "" {
|
||||||
|
if req.CodeChallengeMethod == "S256" {
|
||||||
|
entry.CodeChallenge = req.CodeChallenge
|
||||||
|
} else {
|
||||||
|
entry.CodeChallenge = service.hashAndEncodePKCE(req.CodeChallenge)
|
||||||
|
tlog.App.Warn().Msg("Received plain PKCE code challenge, it's recommended to use S256 for better security")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the code into the database
|
||||||
|
_, err := service.queries.CreateOidcCode(c, entry)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -728,3 +748,16 @@ func (service *OIDCService) GetJWK() ([]byte, error) {
|
|||||||
|
|
||||||
return jwk.Public().MarshalJSON()
|
return jwk.Public().MarshalJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier string) bool {
|
||||||
|
if codeChallenge == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return codeChallenge == service.hashAndEncodePKCE(codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte(codeVerifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ func NewSimpleLogger() *Logger {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewTestLogger() *Logger {
|
||||||
|
return NewLogger(config.LogConfig{
|
||||||
|
Level: "trace",
|
||||||
|
Streams: config.LogStreams{
|
||||||
|
HTTP: config.LogStreamConfig{Enabled: true},
|
||||||
|
App: config.LogStreamConfig{Enabled: true},
|
||||||
|
Audit: config.LogStreamConfig{Enabled: true},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Logger) Init() {
|
func (l *Logger) Init() {
|
||||||
Audit = l.Audit
|
Audit = l.Audit
|
||||||
HTTP = l.HTTP
|
HTTP = l.HTTP
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ INSERT INTO "oidc_codes" (
|
|||||||
"redirect_uri",
|
"redirect_uri",
|
||||||
"client_id",
|
"client_id",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"nonce"
|
"nonce",
|
||||||
|
"code_challenge"
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ CREATE TABLE IF NOT EXISTS "oidc_codes" (
|
|||||||
"redirect_uri" TEXT NOT NULL,
|
"redirect_uri" TEXT NOT NULL,
|
||||||
"client_id" TEXT NOT NULL,
|
"client_id" TEXT NOT NULL,
|
||||||
"expires_at" INTEGER NOT NULL,
|
"expires_at" INTEGER NOT NULL,
|
||||||
"nonce" TEXT DEFAULT ""
|
"nonce" TEXT DEFAULT "",
|
||||||
|
"code_challenge" TEXT DEFAULT ""
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
|
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
|
||||||
|
|||||||
Reference in New Issue
Block a user