Add OAuth2/OpenID Connect authorization server
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization server with support for authorization code flow (with PKCE), refresh token rotation, device authorization grant, dynamic client registration, token introspection, and token revocation. Includes database schema, coredata layer, service logic, HTTP handlers, OIDC discovery endpoint, and JWKS publishing. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
110
pkg/iam/oauth2server/errors.go
Normal file
110
pkg/iam/oauth2server/errors.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// OAuth2Error represents an OAuth2 protocol error with an associated
|
||||
// error code per RFC 6749 §5.2 and RFC 8628 §3.5.
|
||||
type OAuth2Error struct {
|
||||
code string
|
||||
description string
|
||||
}
|
||||
|
||||
func (e *OAuth2Error) Error() string {
|
||||
if e.description != "" {
|
||||
return e.code + ": " + e.description
|
||||
}
|
||||
return e.code
|
||||
}
|
||||
|
||||
func (e *OAuth2Error) ErrorCode() string { return e.code }
|
||||
func (e *OAuth2Error) Description() string { return e.description }
|
||||
|
||||
func (e *OAuth2Error) Is(target error) bool {
|
||||
t, ok := target.(*OAuth2Error)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return e.code == t.code
|
||||
}
|
||||
|
||||
type ErrorOption func(*OAuth2Error)
|
||||
|
||||
func WithDescription(description string) ErrorOption {
|
||||
return func(e *OAuth2Error) {
|
||||
e.description = description
|
||||
}
|
||||
}
|
||||
|
||||
func WithError(err error) ErrorOption {
|
||||
return func(e *OAuth2Error) {
|
||||
e.description = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// NewError creates a new OAuth2Error derived from a sentinel error code.
|
||||
func NewError(code *OAuth2Error, opts ...ErrorOption) *OAuth2Error {
|
||||
e := &OAuth2Error{code: code.code}
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var (
|
||||
// OAuth2 error codes per RFC 6749 §5.2 and RFC 8628 §3.5.
|
||||
ErrInvalidRequest = &OAuth2Error{code: "invalid_request"}
|
||||
ErrInvalidClient = &OAuth2Error{code: "invalid_client"}
|
||||
ErrInvalidGrant = &OAuth2Error{code: "invalid_grant"}
|
||||
ErrUnauthorizedClient = &OAuth2Error{code: "unauthorized_client"}
|
||||
ErrUnsupportedGrantType = &OAuth2Error{code: "unsupported_grant_type"}
|
||||
ErrInvalidScope = &OAuth2Error{code: "invalid_scope"}
|
||||
ErrAccessDenied = &OAuth2Error{code: "access_denied"}
|
||||
ErrServerError = &OAuth2Error{code: "server_error"}
|
||||
ErrInvalidRedirectURI = &OAuth2Error{code: "invalid_redirect_uri"}
|
||||
|
||||
// RFC 7009 revocation errors.
|
||||
ErrUnsupportedTokenType = &OAuth2Error{code: "unsupported_token_type"}
|
||||
|
||||
// RFC 8628 device flow errors.
|
||||
ErrAuthorizationPending = &OAuth2Error{code: "authorization_pending"}
|
||||
ErrSlowDown = &OAuth2Error{code: "slow_down"}
|
||||
ErrExpiredToken = &OAuth2Error{code: "expired_token"}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClientNotFound = errors.New("client not found")
|
||||
ErrConsentNotFound = errors.New("consent not found")
|
||||
ErrDeviceCodeNotPending = errors.New("device code is not pending")
|
||||
ErrUnauthorizedMember = errors.New("user is not a member of the client organization")
|
||||
)
|
||||
|
||||
// ConsentRequiredError is returned by Authorize when the user must approve
|
||||
// the authorization request before a code can be issued.
|
||||
type ConsentRequiredError struct {
|
||||
ConsentID gid.GID
|
||||
Client *coredata.OAuth2Client
|
||||
Scopes coredata.OAuth2Scopes
|
||||
}
|
||||
|
||||
func (e *ConsentRequiredError) Error() string {
|
||||
return "consent required"
|
||||
}
|
||||
126
pkg/iam/oauth2server/gc.go
Normal file
126
pkg/iam/oauth2server/gc.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.gearno.de/kit/worker"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultGCInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
type GarbageCollector = worker.Worker[struct{}]
|
||||
|
||||
type gcHandler struct {
|
||||
pg *pg.Client
|
||||
logger *log.Logger
|
||||
lastRunAt atomic.Int64
|
||||
}
|
||||
|
||||
func NewGarbageCollector(
|
||||
pgClient *pg.Client,
|
||||
logger *log.Logger,
|
||||
opts ...worker.Option,
|
||||
) *GarbageCollector {
|
||||
h := &gcHandler{
|
||||
pg: pgClient,
|
||||
logger: logger.Named("oauth2server.garbage_collector"),
|
||||
}
|
||||
|
||||
return worker.New(
|
||||
"oauth2server.garbage_collector",
|
||||
h,
|
||||
logger,
|
||||
append(
|
||||
[]worker.Option{
|
||||
worker.WithInterval(DefaultGCInterval),
|
||||
worker.WithMaxConcurrency(1),
|
||||
},
|
||||
opts...,
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *gcHandler) Claim(_ context.Context) (struct{}, error) {
|
||||
now := time.Now().UnixNano()
|
||||
last := h.lastRunAt.Load()
|
||||
|
||||
if last > 0 && now-last < int64(DefaultGCInterval) {
|
||||
return struct{}{}, worker.ErrNoTask
|
||||
}
|
||||
|
||||
if !h.lastRunAt.CompareAndSwap(last, now) {
|
||||
return struct{}{}, worker.ErrNoTask
|
||||
}
|
||||
|
||||
return struct{}{}, nil
|
||||
}
|
||||
|
||||
func (h *gcHandler) Process(ctx context.Context, _ struct{}) error {
|
||||
return h.cleanup(ctx)
|
||||
}
|
||||
|
||||
func (h *gcHandler) cleanup(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
|
||||
return h.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
var authCode coredata.OAuth2AuthorizationCode
|
||||
authCodesDeleted, err := authCode.DeleteExpired(ctx, tx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete expired authorization codes: %w", err)
|
||||
}
|
||||
|
||||
var accessToken coredata.OAuth2AccessToken
|
||||
accessTokensDeleted, err := accessToken.DeleteExpired(ctx, tx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete expired access tokens: %w", err)
|
||||
}
|
||||
|
||||
var refreshToken coredata.OAuth2RefreshToken
|
||||
refreshTokensDeleted, err := refreshToken.DeleteExpired(ctx, tx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete expired refresh tokens: %w", err)
|
||||
}
|
||||
|
||||
var deviceCode coredata.OAuth2DeviceCode
|
||||
deviceCodesDeleted, err := deviceCode.DeleteExpired(ctx, tx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete expired device codes: %w", err)
|
||||
}
|
||||
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
"oauth2 server garbage collector cleaned up",
|
||||
log.Int64("authorization_codes_deleted", authCodesDeleted),
|
||||
log.Int64("access_tokens_deleted", accessTokensDeleted),
|
||||
log.Int64("refresh_tokens_deleted", refreshTokensDeleted),
|
||||
log.Int64("device_codes_deleted", deviceCodesDeleted),
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
108
pkg/iam/oauth2server/id_token.go
Normal file
108
pkg/iam/oauth2server/id_token.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type (
|
||||
// SigningKey pairs an RSA private key with its key ID. All entries are
|
||||
// published in the JWKS endpoint. Keys with Active set to true are
|
||||
// used for signing new tokens; when multiple keys are active, the
|
||||
// service round-robins between them.
|
||||
SigningKey struct {
|
||||
PrivateKey *rsa.PrivateKey
|
||||
KID string
|
||||
Active bool
|
||||
}
|
||||
|
||||
IDTokenClaims struct {
|
||||
Issuer uri.URI `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
AuthTime int64 `json:"auth_time"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
AtHash string `json:"at_hash,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified *bool `json:"email_verified,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Scope coredata.OAuth2Scopes `json:"-"`
|
||||
}
|
||||
|
||||
SigningKeys []SigningKey
|
||||
)
|
||||
|
||||
// ComputeAtHash computes the at_hash claim value for an access token.
|
||||
// Per OIDC Core §3.1.3.6: left half of SHA-256 hash, base64url-encoded.
|
||||
func ComputeAtHash(accessToken string) string {
|
||||
h := sha256.Sum256([]byte(accessToken))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:16])
|
||||
}
|
||||
|
||||
func NewIDTokenClaims(
|
||||
issuer uri.URI,
|
||||
identityID gid.GID,
|
||||
clientID gid.GID,
|
||||
authTime time.Time,
|
||||
scopes coredata.OAuth2Scopes,
|
||||
nonce string,
|
||||
accessToken string,
|
||||
email string,
|
||||
emailVerified bool,
|
||||
fullName string,
|
||||
ttl time.Duration,
|
||||
) *IDTokenClaims {
|
||||
now := time.Now()
|
||||
|
||||
claims := &IDTokenClaims{
|
||||
Issuer: issuer,
|
||||
Subject: identityID.String(),
|
||||
Audience: clientID.String(),
|
||||
ExpiresAt: now.Add(ttl).Unix(),
|
||||
IssuedAt: now.Unix(),
|
||||
AuthTime: authTime.Unix(),
|
||||
Scope: scopes,
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims.Nonce = nonce
|
||||
}
|
||||
|
||||
if accessToken != "" {
|
||||
claims.AtHash = ComputeAtHash(accessToken)
|
||||
}
|
||||
|
||||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case coredata.OAuth2ScopeEmail:
|
||||
claims.Email = email
|
||||
claims.EmailVerified = &emailVerified
|
||||
case coredata.OAuth2ScopeProfile:
|
||||
claims.Name = fullName
|
||||
}
|
||||
}
|
||||
|
||||
return claims
|
||||
}
|
||||
300
pkg/iam/oauth2server/id_token_test.go
Normal file
300
pkg/iam/oauth2server/id_token_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam/oauth2server"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
var testIssuer = uri.URI("https://issuer.example.com")
|
||||
|
||||
func TestComputeAtHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns left half of sha256 base64url encoded",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accessToken := "ya29.test-access-token"
|
||||
h := sha256.Sum256([]byte(accessToken))
|
||||
expected := base64.RawURLEncoding.EncodeToString(h[:16])
|
||||
|
||||
result := oauth2server.ComputeAtHash(accessToken)
|
||||
assert.Equal(t, expected, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"different tokens produce different hashes",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash1 := oauth2server.ComputeAtHash("token-a")
|
||||
hash2 := oauth2server.ComputeAtHash("token-b")
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty token",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ComputeAtHash("")
|
||||
assert.NotEmpty(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"deterministic",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash1 := oauth2server.ComputeAtHash("same-token")
|
||||
hash2 := oauth2server.ComputeAtHash("same-token")
|
||||
assert.Equal(t, hash1, hash2)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestNewIDTokenClaims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identityID := gid.Nil
|
||||
clientID := gid.Nil
|
||||
authTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run(
|
||||
"basic claims without optional scopes",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
||||
"",
|
||||
"",
|
||||
"user@example.com",
|
||||
true,
|
||||
"John Doe",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
assert.Equal(t, testIssuer, claims.Issuer)
|
||||
assert.Equal(t, identityID.String(), claims.Subject)
|
||||
assert.Equal(t, clientID.String(), claims.Audience)
|
||||
assert.Equal(t, authTime.Unix(), claims.AuthTime)
|
||||
assert.Empty(t, claims.Nonce)
|
||||
assert.Empty(t, claims.AtHash)
|
||||
assert.Empty(t, claims.Email)
|
||||
assert.Nil(t, claims.EmailVerified)
|
||||
assert.Empty(t, claims.Name)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"sets nonce when provided",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
||||
"test-nonce",
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
assert.Equal(t, "test-nonce", claims.Nonce)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"computes at_hash when access token provided",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
||||
"",
|
||||
"access-token-123",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
expected := oauth2server.ComputeAtHash("access-token-123")
|
||||
assert.Equal(t, expected, claims.AtHash)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"includes email claims with email scope",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
|
||||
"",
|
||||
"",
|
||||
"user@example.com",
|
||||
true,
|
||||
"",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
assert.Equal(t, "user@example.com", claims.Email)
|
||||
require.NotNil(t, claims.EmailVerified)
|
||||
assert.True(t, *claims.EmailVerified)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"includes name with profile scope",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeProfile},
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"Jane Doe",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
assert.Equal(t, "Jane Doe", claims.Name)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"includes all claims with all scopes",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{
|
||||
coredata.OAuth2ScopeOpenID,
|
||||
coredata.OAuth2ScopeEmail,
|
||||
coredata.OAuth2ScopeProfile,
|
||||
},
|
||||
"nonce-val",
|
||||
"access-token",
|
||||
"user@example.com",
|
||||
false,
|
||||
"John Doe",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
assert.Equal(t, "nonce-val", claims.Nonce)
|
||||
assert.NotEmpty(t, claims.AtHash)
|
||||
assert.Equal(t, "user@example.com", claims.Email)
|
||||
require.NotNil(t, claims.EmailVerified)
|
||||
assert.False(t, *claims.EmailVerified)
|
||||
assert.Equal(t, "John Doe", claims.Name)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"sets expiration based on ttl",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ttl := 2 * time.Hour
|
||||
before := time.Now()
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
ttl,
|
||||
)
|
||||
after := time.Now()
|
||||
|
||||
assert.GreaterOrEqual(t, claims.ExpiresAt, before.Add(ttl).Unix())
|
||||
assert.LessOrEqual(t, claims.ExpiresAt, after.Add(ttl).Unix())
|
||||
assert.GreaterOrEqual(t, claims.IssuedAt, before.Unix())
|
||||
assert.LessOrEqual(t, claims.IssuedAt, after.Unix())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"email not verified",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
claims := oauth2server.NewIDTokenClaims(
|
||||
testIssuer,
|
||||
identityID,
|
||||
clientID,
|
||||
authTime,
|
||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
|
||||
"",
|
||||
"",
|
||||
"user@example.com",
|
||||
false,
|
||||
"",
|
||||
1*time.Hour,
|
||||
)
|
||||
|
||||
require.NotNil(t, claims.EmailVerified)
|
||||
assert.False(t, *claims.EmailVerified)
|
||||
},
|
||||
)
|
||||
}
|
||||
123
pkg/iam/oauth2server/metadata.go
Normal file
123
pkg/iam/oauth2server/metadata.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
type (
|
||||
// ServerMetadata represents the OpenID Connect Discovery 1.0 / RFC 8414
|
||||
// authorization server metadata document.
|
||||
ServerMetadata struct {
|
||||
Issuer uri.URI `json:"issuer"`
|
||||
AuthorizationEndpoint uri.URI `json:"authorization_endpoint"`
|
||||
TokenEndpoint uri.URI `json:"token_endpoint"`
|
||||
UserinfoEndpoint uri.URI `json:"userinfo_endpoint"`
|
||||
JwksURI uri.URI `json:"jwks_uri"`
|
||||
RegistrationEndpoint uri.URI `json:"registration_endpoint"`
|
||||
IntrospectionEndpoint uri.URI `json:"introspection_endpoint"`
|
||||
RevocationEndpoint uri.URI `json:"revocation_endpoint"`
|
||||
DeviceAuthorizationEndpoint uri.URI `json:"device_authorization_endpoint"`
|
||||
ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"`
|
||||
ResponseTypesSupported []coredata.OAuth2ResponseType `json:"response_types_supported"`
|
||||
GrantTypesSupported []coredata.OAuth2GrantType `json:"grant_types_supported"`
|
||||
TokenEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_methods_supported"`
|
||||
RevocationEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"revocation_endpoint_auth_methods_supported"`
|
||||
IntrospectionEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"introspection_endpoint_auth_methods_supported"`
|
||||
SubjectTypesSupported []coredata.OAuth2SubjectType `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []coredata.OAuth2SigningAlgorithm `json:"id_token_signing_alg_values_supported"`
|
||||
CodeChallengeMethodsSupported []coredata.OAuth2CodeChallengeMethod `json:"code_challenge_methods_supported"`
|
||||
ClaimsSupported []coredata.OAuth2Claim `json:"claims_supported"`
|
||||
}
|
||||
|
||||
// Endpoints holds the endpoint URLs for the OIDC discovery document.
|
||||
Endpoints struct {
|
||||
Authorization uri.URI
|
||||
Token uri.URI
|
||||
Userinfo uri.URI
|
||||
JWKS uri.URI
|
||||
Registration uri.URI
|
||||
Introspection uri.URI
|
||||
Revocation uri.URI
|
||||
DeviceAuthorization uri.URI
|
||||
}
|
||||
)
|
||||
|
||||
func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata {
|
||||
return &ServerMetadata{
|
||||
Issuer: issuer,
|
||||
AuthorizationEndpoint: endpoints.Authorization,
|
||||
TokenEndpoint: endpoints.Token,
|
||||
UserinfoEndpoint: endpoints.Userinfo,
|
||||
JwksURI: endpoints.JWKS,
|
||||
RegistrationEndpoint: endpoints.Registration,
|
||||
IntrospectionEndpoint: endpoints.Introspection,
|
||||
RevocationEndpoint: endpoints.Revocation,
|
||||
DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization,
|
||||
ScopesSupported: []coredata.OAuth2Scope{
|
||||
coredata.OAuth2ScopeOpenID,
|
||||
coredata.OAuth2ScopeProfile,
|
||||
coredata.OAuth2ScopeEmail,
|
||||
coredata.OAuth2ScopeOfflineAccess,
|
||||
},
|
||||
ResponseTypesSupported: []coredata.OAuth2ResponseType{
|
||||
coredata.OAuth2ResponseTypeCode,
|
||||
},
|
||||
GrantTypesSupported: []coredata.OAuth2GrantType{
|
||||
coredata.OAuth2GrantTypeAuthorizationCode,
|
||||
coredata.OAuth2GrantTypeRefreshToken,
|
||||
coredata.OAuth2GrantTypeDeviceCode,
|
||||
},
|
||||
TokenEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
|
||||
},
|
||||
RevocationEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
|
||||
},
|
||||
IntrospectionEndpointAuthMethodsSupported: []coredata.OAuth2ClientTokenEndpointAuthMethod{
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
|
||||
},
|
||||
SubjectTypesSupported: []coredata.OAuth2SubjectType{
|
||||
coredata.OAuth2SubjectTypePublic,
|
||||
},
|
||||
IDTokenSigningAlgValuesSupported: []coredata.OAuth2SigningAlgorithm{
|
||||
coredata.OAuth2SigningAlgorithmRS256,
|
||||
},
|
||||
CodeChallengeMethodsSupported: []coredata.OAuth2CodeChallengeMethod{
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
},
|
||||
ClaimsSupported: []coredata.OAuth2Claim{
|
||||
coredata.OAuth2ClaimIssuer,
|
||||
coredata.OAuth2ClaimSubject,
|
||||
coredata.OAuth2ClaimAudience,
|
||||
coredata.OAuth2ClaimExpiration,
|
||||
coredata.OAuth2ClaimIssuedAt,
|
||||
coredata.OAuth2ClaimAuthTime,
|
||||
coredata.OAuth2ClaimNonce,
|
||||
coredata.OAuth2ClaimAtHash,
|
||||
coredata.OAuth2ClaimEmail,
|
||||
coredata.OAuth2ClaimEmailVerified,
|
||||
coredata.OAuth2ClaimName,
|
||||
},
|
||||
}
|
||||
}
|
||||
240
pkg/iam/oauth2server/metadata_test.go
Normal file
240
pkg/iam/oauth2server/metadata_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/iam/oauth2server"
|
||||
"go.probo.inc/probo/pkg/uri"
|
||||
)
|
||||
|
||||
func TestNewMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
issuer := uri.URI("https://auth.example.com")
|
||||
endpoints := oauth2server.Endpoints{
|
||||
Authorization: "https://auth.example.com/authorize",
|
||||
Token: "https://auth.example.com/token",
|
||||
Userinfo: "https://auth.example.com/userinfo",
|
||||
JWKS: "https://auth.example.com/.well-known/jwks.json",
|
||||
Registration: "https://auth.example.com/register",
|
||||
Introspection: "https://auth.example.com/introspect",
|
||||
Revocation: "https://auth.example.com/revoke",
|
||||
DeviceAuthorization: "https://auth.example.com/device",
|
||||
}
|
||||
|
||||
metadata := oauth2server.NewMetadata(issuer, endpoints)
|
||||
require.NotNil(t, metadata)
|
||||
|
||||
t.Run(
|
||||
"issuer",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, issuer, metadata.Issuer)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"endpoints",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, endpoints.Authorization, metadata.AuthorizationEndpoint)
|
||||
assert.Equal(t, endpoints.Token, metadata.TokenEndpoint)
|
||||
assert.Equal(t, endpoints.Userinfo, metadata.UserinfoEndpoint)
|
||||
assert.Equal(t, endpoints.JWKS, metadata.JwksURI)
|
||||
assert.Equal(t, endpoints.Registration, metadata.RegistrationEndpoint)
|
||||
assert.Equal(t, endpoints.Introspection, metadata.IntrospectionEndpoint)
|
||||
assert.Equal(t, endpoints.Revocation, metadata.RevocationEndpoint)
|
||||
assert.Equal(t, endpoints.DeviceAuthorization, metadata.DeviceAuthorizationEndpoint)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"scopes supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2Scope{
|
||||
coredata.OAuth2ScopeOpenID,
|
||||
coredata.OAuth2ScopeProfile,
|
||||
coredata.OAuth2ScopeEmail,
|
||||
coredata.OAuth2ScopeOfflineAccess,
|
||||
},
|
||||
metadata.ScopesSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"response types supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2ResponseType{
|
||||
coredata.OAuth2ResponseTypeCode,
|
||||
},
|
||||
metadata.ResponseTypesSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"grant types supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2GrantType{
|
||||
coredata.OAuth2GrantTypeAuthorizationCode,
|
||||
coredata.OAuth2GrantTypeRefreshToken,
|
||||
coredata.OAuth2GrantTypeDeviceCode,
|
||||
},
|
||||
metadata.GrantTypesSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"token endpoint auth methods supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2ClientTokenEndpointAuthMethod{
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
|
||||
},
|
||||
metadata.TokenEndpointAuthMethodsSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"revocation endpoint auth methods supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2ClientTokenEndpointAuthMethod{
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
|
||||
},
|
||||
metadata.RevocationEndpointAuthMethodsSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"introspection endpoint auth methods supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2ClientTokenEndpointAuthMethod{
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretPost,
|
||||
coredata.OAuth2ClientTokenEndpointAuthMethodNone,
|
||||
},
|
||||
metadata.IntrospectionEndpointAuthMethodsSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"subject types supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2SubjectType{
|
||||
coredata.OAuth2SubjectTypePublic,
|
||||
},
|
||||
metadata.SubjectTypesSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"id token signing algorithms supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2SigningAlgorithm{
|
||||
coredata.OAuth2SigningAlgorithmRS256,
|
||||
},
|
||||
metadata.IDTokenSigningAlgValuesSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"code challenge methods supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2CodeChallengeMethod{
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
},
|
||||
metadata.CodeChallengeMethodsSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"claims supported",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]coredata.OAuth2Claim{
|
||||
coredata.OAuth2ClaimIssuer,
|
||||
coredata.OAuth2ClaimSubject,
|
||||
coredata.OAuth2ClaimAudience,
|
||||
coredata.OAuth2ClaimExpiration,
|
||||
coredata.OAuth2ClaimIssuedAt,
|
||||
coredata.OAuth2ClaimAuthTime,
|
||||
coredata.OAuth2ClaimNonce,
|
||||
coredata.OAuth2ClaimAtHash,
|
||||
coredata.OAuth2ClaimEmail,
|
||||
coredata.OAuth2ClaimEmailVerified,
|
||||
coredata.OAuth2ClaimName,
|
||||
},
|
||||
metadata.ClaimsSupported,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
43
pkg/iam/oauth2server/pkce.go
Normal file
43
pkg/iam/oauth2server/pkce.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func ValidateCodeChallenge(verifier, challenge string, method coredata.OAuth2CodeChallengeMethod) bool {
|
||||
if verifier == "" || challenge == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch method {
|
||||
case coredata.OAuth2CodeChallengeMethodS256:
|
||||
return validateS256(verifier, challenge)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateS256(verifier, challenge string) bool {
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
computed := base64.RawURLEncoding.EncodeToString(h[:])
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
|
||||
}
|
||||
158
pkg/iam/oauth2server/pkce_test.go
Normal file
158
pkg/iam/oauth2server/pkce_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package oauth2server_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/iam/oauth2server"
|
||||
)
|
||||
|
||||
func computeS256Challenge(verifier string) string {
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func TestValidateCodeChallenge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge := computeS256Challenge(verifier)
|
||||
|
||||
t.Run(
|
||||
"valid s256",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
verifier,
|
||||
challenge,
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
)
|
||||
|
||||
require.True(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"wrong verifier",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
"wrong-verifier",
|
||||
challenge,
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"wrong challenge",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
verifier,
|
||||
"wrong-challenge",
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"unsupported method",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
verifier,
|
||||
challenge,
|
||||
coredata.OAuth2CodeChallengeMethod("plain"),
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty method",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
verifier,
|
||||
challenge,
|
||||
coredata.OAuth2CodeChallengeMethod(""),
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty verifier",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
"",
|
||||
challenge,
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty challenge",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
verifier,
|
||||
"",
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"both empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := oauth2server.ValidateCodeChallenge(
|
||||
"",
|
||||
"",
|
||||
coredata.OAuth2CodeChallengeMethodS256,
|
||||
)
|
||||
|
||||
assert.False(t, result)
|
||||
},
|
||||
)
|
||||
}
|
||||
1675
pkg/iam/oauth2server/service.go
Normal file
1675
pkg/iam/oauth2server/service.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user