mirror of
https://github.com/nais/wonderwall.git
synced 2026-08-20 11:36:14 +00:00
Each finding was reviewed individually; the annotations record why the flagged code is safe rather than suppressing the rules globally. - G101: viper configuration keys, not credentials - G117: the marshalled value is the plaintext input to the encryption that immediately follows, or a token endpoint response that is required to carry tokens - G118: the background deletion must outlive the request, so the request context deliberately is not used - G124: cookie attributes are validated in config.Cookie.Validate - G710: the redirect targets are relative by construction, validated by the url validators, or read from the provider's metadata document
318 lines
8.6 KiB
Go
318 lines
8.6 KiB
Go
package session
|
|
|
|
import (
|
|
"encoding"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/nais/wonderwall/internal/crypto"
|
|
"github.com/nais/wonderwall/pkg/openid"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
const (
|
|
// RefreshMinInterval is the minimum wait between refreshes (the cooldown).
|
|
RefreshMinInterval = 1 * time.Minute
|
|
// RefreshLeeway is how long before expiry we aim to refresh.
|
|
RefreshLeeway = 30 * time.Second
|
|
// SecondsNotApplicable marks a *_in_seconds field whose feature is
|
|
// disabled: no inactivity timeout, or auto-refresh turned off.
|
|
SecondsNotApplicable int64 = -1
|
|
)
|
|
|
|
type EncryptedData struct {
|
|
Ciphertext []byte
|
|
}
|
|
|
|
var (
|
|
_ encoding.BinaryMarshaler = &EncryptedData{}
|
|
_ encoding.BinaryUnmarshaler = &EncryptedData{}
|
|
)
|
|
|
|
func (in *EncryptedData) MarshalBinary() ([]byte, error) {
|
|
return in.Ciphertext, nil
|
|
}
|
|
|
|
func (in *EncryptedData) UnmarshalBinary(bytes []byte) error {
|
|
in.Ciphertext = bytes
|
|
return nil
|
|
}
|
|
|
|
func (in *EncryptedData) Decrypt(crypter crypto.Crypter) (*Data, error) {
|
|
rawData, err := crypter.Decrypt(in.Ciphertext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var data Data
|
|
err = json.Unmarshal(rawData, &data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
type Data struct {
|
|
ExternalSessionID string `json:"external_session_id"`
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
Acr string `json:"acr"`
|
|
Metadata Metadata `json:"metadata"`
|
|
}
|
|
|
|
func NewData(externalSessionID string, tokens *openid.Tokens, metadata *Metadata) *Data {
|
|
data := &Data{
|
|
ExternalSessionID: externalSessionID,
|
|
AccessToken: tokens.AccessToken,
|
|
IDToken: tokens.IDToken.Serialized(),
|
|
RefreshToken: tokens.RefreshToken,
|
|
Acr: tokens.IDToken.Acr(),
|
|
}
|
|
|
|
if metadata != nil {
|
|
data.Metadata = *metadata
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
func (in *Data) Encrypt(crypter crypto.Crypter) (*EncryptedData, error) {
|
|
// #nosec G117 -- the marshalled data is the plaintext input to the encryption below
|
|
bytes, err := json.Marshal(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ciphertext, err := crypter.Encrypt(bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &EncryptedData{
|
|
Ciphertext: ciphertext,
|
|
}, nil
|
|
}
|
|
|
|
func (in *Data) HasAccessToken() bool {
|
|
return len(in.AccessToken) > 0
|
|
}
|
|
|
|
func (in *Data) HasActiveAccessToken() bool {
|
|
return in.HasAccessToken() && !in.Metadata.IsExpired()
|
|
}
|
|
|
|
func (in *Data) HasRefreshToken() bool {
|
|
return len(in.RefreshToken) > 0
|
|
}
|
|
|
|
func (in *Data) Validate() error {
|
|
if !in.HasAccessToken() {
|
|
return fmt.Errorf("%w: no access token in data", ErrInvalid)
|
|
}
|
|
|
|
if in.Metadata.IsEnded() {
|
|
return fmt.Errorf("%w: has ended", ErrInvalid)
|
|
}
|
|
|
|
if in.Metadata.IsTimedOut() {
|
|
return fmt.Errorf("%w: %w", ErrInvalid, ErrInactive)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type Metadata struct {
|
|
Session MetadataSession `json:"session"`
|
|
Tokens MetadataTokens `json:"tokens"`
|
|
}
|
|
|
|
type MetadataSession struct {
|
|
// CreatedAt is the time when the session was created.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// EndsAt is the time when the session will end, i.e. the absolute lifetime/time-to-live for the session.
|
|
EndsAt time.Time `json:"ends_at"`
|
|
// TimeoutAt is the time when the session will be marked as inactive. A zero value means no timeout. The timeout is extended whenever the tokens are refreshed.
|
|
TimeoutAt time.Time `json:"timeout_at"`
|
|
}
|
|
|
|
type MetadataTokens struct {
|
|
// ExpireAt is the time when the tokens will expire.
|
|
ExpireAt time.Time `json:"expire_at"`
|
|
// RefreshedAt is the time when the tokens were last refreshed.
|
|
RefreshedAt time.Time `json:"refreshed_at"`
|
|
}
|
|
|
|
func NewMetadata(expiresIn, endsIn time.Duration) *Metadata {
|
|
now := time.Now()
|
|
return &Metadata{
|
|
Session: MetadataSession{
|
|
CreatedAt: now,
|
|
EndsAt: now.Add(endsIn),
|
|
},
|
|
Tokens: MetadataTokens{
|
|
ExpireAt: now.Add(expiresIn),
|
|
RefreshedAt: now,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (in *Metadata) IsEnded() bool {
|
|
return time.Now().After(in.Session.EndsAt)
|
|
}
|
|
|
|
func (in *Metadata) IsExpired() bool {
|
|
return time.Now().After(in.Tokens.ExpireAt)
|
|
}
|
|
|
|
func (in *Metadata) IsRefreshOnCooldown() bool {
|
|
return time.Now().Before(in.RefreshCooldown())
|
|
}
|
|
|
|
// NextRefresh returns when we should next proactively refresh the tokens. It
|
|
// aims to refresh shortly before expiry, but never before the cooldown.
|
|
func (in *Metadata) NextRefresh() time.Time {
|
|
// aim to refresh shortly before the tokens expire
|
|
next := in.Tokens.ExpireAt.Add(-RefreshLeeway)
|
|
|
|
// with inactivity enabled, refresh earlier - at the midpoint to the timeout
|
|
// - so an active user extends the timeout well before the session goes idle
|
|
timeout := in.Session.TimeoutAt
|
|
if !timeout.IsZero() {
|
|
lastRefresh := in.Tokens.RefreshedAt
|
|
halfLife := lastRefresh.Add(timeout.Sub(lastRefresh) / 2)
|
|
|
|
if halfLife.Before(next) {
|
|
next = halfLife
|
|
}
|
|
}
|
|
|
|
// but never before the cooldown has elapsed
|
|
if cooldown := in.RefreshCooldown(); next.Before(cooldown) {
|
|
next = cooldown
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
func (in *Metadata) Refresh(nextExpirySeconds int64) {
|
|
now := time.Now()
|
|
in.Tokens.RefreshedAt = now
|
|
in.Tokens.ExpireAt = now.Add(time.Duration(nextExpirySeconds) * time.Second)
|
|
}
|
|
|
|
// RefreshCooldown returns the earliest time the tokens may be refreshed again,
|
|
// throttling calls to the identity provider. It waits RefreshMinInterval after
|
|
// the last refresh, but caps short-lived tokens at half their lifetime so the
|
|
// cooldown always leaves room to refresh before they expire.
|
|
func (in *Metadata) RefreshCooldown() time.Time {
|
|
refreshed := in.Tokens.RefreshedAt
|
|
tokenLifetime := in.TokenLifetime()
|
|
|
|
if tokenLifetime <= RefreshMinInterval*2 {
|
|
return refreshed.Add(tokenLifetime / 2)
|
|
}
|
|
|
|
return refreshed.Add(RefreshMinInterval)
|
|
}
|
|
|
|
func (in *Metadata) ShouldRefresh() bool {
|
|
if in.IsExpired() {
|
|
return true
|
|
}
|
|
|
|
return time.Now().After(in.NextRefresh())
|
|
}
|
|
|
|
func (in *Metadata) TokenLifetime() time.Duration {
|
|
return in.Tokens.ExpireAt.Sub(in.Tokens.RefreshedAt)
|
|
}
|
|
|
|
func (in *Metadata) IsTimedOut() bool {
|
|
if in.Session.TimeoutAt.IsZero() {
|
|
return false
|
|
}
|
|
|
|
return time.Now().After(in.Session.TimeoutAt)
|
|
}
|
|
|
|
func (in *Metadata) WithTimeout(timeoutIn time.Duration) {
|
|
timeoutAt := time.Now().Add(timeoutIn)
|
|
in.Session.TimeoutAt = timeoutAt
|
|
|
|
if timeoutAt.Before(in.Tokens.ExpireAt) {
|
|
in.Tokens.ExpireAt = timeoutAt
|
|
}
|
|
}
|
|
|
|
func (in *Metadata) Verbose() MetadataVerbose {
|
|
now := time.Now()
|
|
|
|
timeoutInSeconds := SecondsNotApplicable
|
|
if !in.Session.TimeoutAt.IsZero() {
|
|
timeoutInSeconds = toSeconds(in.Session.TimeoutAt.Sub(now))
|
|
}
|
|
|
|
return MetadataVerbose{
|
|
Session: MetadataSessionVerbose{
|
|
MetadataSession: in.Session,
|
|
EndsInSeconds: toSeconds(in.Session.EndsAt.Sub(now)),
|
|
Active: !in.IsTimedOut(),
|
|
TimeoutInSeconds: timeoutInSeconds,
|
|
},
|
|
Tokens: MetadataTokensVerbose{
|
|
MetadataTokens: in.Tokens,
|
|
ExpireInSeconds: toSeconds(in.Tokens.ExpireAt.Sub(now)),
|
|
NextAutoRefreshInSeconds: toSeconds(in.NextRefresh().Sub(now)),
|
|
RefreshCooldown: in.IsRefreshOnCooldown(),
|
|
RefreshCooldownSeconds: toSeconds(in.RefreshCooldown().Sub(now)),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (in *Metadata) SetSpanAttributes(span trace.Span) {
|
|
span.SetAttributes(attribute.String("session.token_expires_at", in.Tokens.ExpireAt.Format(time.RFC3339)))
|
|
span.SetAttributes(attribute.String("session.token_refreshed_at", in.Tokens.RefreshedAt.Format(time.RFC3339)))
|
|
span.SetAttributes(attribute.String("session.created_at", in.Session.CreatedAt.Format(time.RFC3339)))
|
|
span.SetAttributes(attribute.String("session.ends_at", in.Session.EndsAt.Format(time.RFC3339)))
|
|
if !in.Session.TimeoutAt.IsZero() {
|
|
span.SetAttributes(attribute.String("session.timeout_at", in.Session.TimeoutAt.Format(time.RFC3339)))
|
|
}
|
|
}
|
|
|
|
type MetadataVerbose struct {
|
|
Session MetadataSessionVerbose `json:"session"`
|
|
Tokens MetadataTokensVerbose `json:"tokens"`
|
|
}
|
|
|
|
type MetadataSessionVerbose struct {
|
|
MetadataSession
|
|
EndsInSeconds int64 `json:"ends_in_seconds"`
|
|
Active bool `json:"active"`
|
|
// TimeoutInSeconds is the seconds until the inactivity timeout, or
|
|
// SecondsNotApplicable when no timeout is configured.
|
|
TimeoutInSeconds int64 `json:"timeout_in_seconds"`
|
|
}
|
|
|
|
type MetadataTokensVerbose struct {
|
|
MetadataTokens
|
|
ExpireInSeconds int64 `json:"expire_in_seconds"`
|
|
// NextAutoRefreshInSeconds is the seconds until the next automatic token
|
|
// refresh, or SecondsNotApplicable when auto-refresh is disabled.
|
|
NextAutoRefreshInSeconds int64 `json:"next_auto_refresh_in_seconds"`
|
|
RefreshCooldown bool `json:"refresh_cooldown"`
|
|
RefreshCooldownSeconds int64 `json:"refresh_cooldown_seconds"`
|
|
}
|
|
|
|
func toSeconds(d time.Duration) int64 {
|
|
i := int64(d.Seconds())
|
|
if i <= 0 {
|
|
return 0
|
|
}
|
|
|
|
return i
|
|
}
|