mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-11 12:19:04 +02:00
83 lines
3.5 KiB
Go
83 lines
3.5 KiB
Go
package oidc
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pocket-id/fosite"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestDeviceCodeExchangeConsumesOpenIDConnectSession(t *testing.T) {
|
|
const (
|
|
userID = "test-user"
|
|
clientID = "test-client"
|
|
)
|
|
service, store, provider, userCode, deviceCode := newTestDeviceServiceWithCode(t, clientID, userID, false, nil)
|
|
require.NoError(t, service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{}))
|
|
|
|
signature, err := provider.deviceStrategy.DeviceCodeSignature(t.Context(), deviceCode)
|
|
require.NoError(t, err)
|
|
|
|
// The approved authorization keeps an OpenID Connect session that expires together with the device code
|
|
var deviceSession, openIDSession OAuth2Session
|
|
require.NoError(t, store.db.First(&deviceSession, "kind = ? AND key = ?", sessionKindDeviceCode, signature).Error)
|
|
require.NoError(t, store.db.First(&openIDSession, "kind = ? AND key = ?", sessionKindOpenID, signature).Error)
|
|
require.NotNil(t, deviceSession.ExpiresAt)
|
|
require.NotNil(t, openIDSession.ExpiresAt)
|
|
require.True(t, deviceSession.ExpiresAt.ToTime().Equal(openIDSession.ExpiresAt.ToTime()))
|
|
|
|
// The token exchange issues an ID token and consumes the OpenID Connect session
|
|
response, err := exchangeDeviceCode(t, provider, clientID, deviceCode)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, response.GetExtra("id_token"))
|
|
|
|
var count int64
|
|
require.NoError(t, store.db.Model(&OAuth2Session{}).Where("kind = ?", sessionKindOpenID).Count(&count).Error)
|
|
require.Zero(t, count)
|
|
require.NoError(t, store.db.Model(&OAuth2Session{}).Where("kind = ? AND request_id = ?", sessionKindAccessToken, deviceSession.RequestID).Count(&count).Error)
|
|
require.EqualValues(t, 1, count)
|
|
|
|
// A replayed device code is rejected before an OpenID Connect session could be reused
|
|
_, err = exchangeDeviceCode(t, provider, clientID, deviceCode)
|
|
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
|
|
|
|
// The replay revokes the access token that the first exchange issued
|
|
require.NoError(t, store.db.Model(&OAuth2Session{}).Where("kind = ? AND request_id = ?", sessionKindAccessToken, deviceSession.RequestID).Count(&count).Error)
|
|
require.Zero(t, count)
|
|
}
|
|
|
|
func TestStoreInvalidateDeviceCodeSessionReportsLostRaceAsInvalidGrant(t *testing.T) {
|
|
_, store, provider, _, deviceCode := newTestDeviceServiceWithCode(t, "test-client", "test-user", false, nil)
|
|
|
|
signature, err := provider.deviceStrategy.DeviceCodeSignature(t.Context(), deviceCode)
|
|
require.NoError(t, err)
|
|
|
|
// The first redemption wins and a concurrent second one must not mint another token set
|
|
require.NoError(t, store.InvalidateDeviceCodeSession(t.Context(), signature))
|
|
require.ErrorIs(t, store.InvalidateDeviceCodeSession(t.Context(), signature), fosite.ErrInvalidGrant)
|
|
}
|
|
|
|
// exchangeDeviceCode redeems the device code at the token endpoint like a polling device would
|
|
func exchangeDeviceCode(t *testing.T, provider *oidcProvider, clientID, deviceCode string) (fosite.AccessResponder, error) {
|
|
t.Helper()
|
|
|
|
form := url.Values{
|
|
"grant_type": {string(fosite.GrantTypeDeviceCode)},
|
|
"device_code": {deviceCode},
|
|
"client_id": {clientID},
|
|
}
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
accessRequest, err := provider.NewAccessRequest(t.Context(), req, NewEmptySession())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return provider.NewAccessResponse(t.Context(), accessRequest)
|
|
}
|