authkit initial
This commit is contained in:
parent
5173b0a43d
commit
134393fbca
43 changed files with 5188 additions and 1 deletions
147
service_jwt.go
Normal file
147
service_jwt.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package authkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.juancwu.dev/juancwu/errx"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// IssueJWT issues a fresh access JWT and a rotating opaque refresh token.
|
||||
// The refresh token is bound to a chain via Token.ChainID; rotation
|
||||
// preserves that chain so reuse-detection can revoke the whole family.
|
||||
func (a *Auth) IssueJWT(ctx context.Context, userID uuid.UUID) (access, refresh string, err error) {
|
||||
const op = "authkit.Auth.IssueJWT"
|
||||
u, err := a.deps.Users.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return "", "", errx.Wrap(op, err)
|
||||
}
|
||||
access, err = a.signAccessToken(u.ID, u.SessionVersion)
|
||||
if err != nil {
|
||||
return "", "", errx.Wrap(op, err)
|
||||
}
|
||||
refresh, err = a.mintRefreshToken(ctx, u.ID, uuid.NewString())
|
||||
if err != nil {
|
||||
return "", "", errx.Wrap(op, err)
|
||||
}
|
||||
return access, refresh, nil
|
||||
}
|
||||
|
||||
// AuthenticateJWT validates the access JWT, cross-checks the user's
|
||||
// session_version (instant revocation), and resolves a Principal.
|
||||
func (a *Auth) AuthenticateJWT(ctx context.Context, access string) (*Principal, error) {
|
||||
const op = "authkit.Auth.AuthenticateJWT"
|
||||
claims, err := a.parseAccessToken(access)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
uid, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, ErrTokenInvalid)
|
||||
}
|
||||
u, err := a.deps.Users.GetUserByID(ctx, uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return nil, errx.Wrap(op, ErrTokenInvalid)
|
||||
}
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
if u.SessionVersion != claims.SessionVersion {
|
||||
return nil, errx.Wrap(op, ErrTokenInvalid)
|
||||
}
|
||||
roles, perms, err := a.resolveRolesAndPermissions(ctx, u.ID)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
return &Principal{
|
||||
UserID: u.ID,
|
||||
Method: AuthMethodJWT,
|
||||
Roles: roles,
|
||||
Permissions: perms,
|
||||
IssuedAt: claims.IssuedAt.Time,
|
||||
ExpiresAt: claims.ExpiresAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshJWT consumes the presented refresh token and mints a new access +
|
||||
// refresh pair. Reuse of an already-consumed refresh token deletes the
|
||||
// entire chain (logout-everywhere on that device family) and returns
|
||||
// ErrTokenReused.
|
||||
func (a *Auth) RefreshJWT(ctx context.Context, plaintextRefresh string) (access, refresh string, err error) {
|
||||
const op = "authkit.Auth.RefreshJWT"
|
||||
hash, ok := parseSecret(prefixRefresh, plaintextRefresh)
|
||||
if !ok {
|
||||
return "", "", errx.Wrap(op, ErrTokenInvalid)
|
||||
}
|
||||
now := a.now()
|
||||
|
||||
consumed, err := a.deps.Tokens.ConsumeToken(ctx, TokenRefresh, hash, now)
|
||||
if err != nil {
|
||||
// Differentiate plain-invalid (never existed / expired) from
|
||||
// reuse (existed, already consumed). The presence-check below is
|
||||
// the reuse signal.
|
||||
if errors.Is(err, ErrTokenInvalid) {
|
||||
if existing, gerr := a.deps.Tokens.GetToken(ctx, TokenRefresh, hash); gerr == nil && existing.ConsumedAt != nil {
|
||||
if existing.ChainID != nil && *existing.ChainID != "" {
|
||||
_, _ = a.deps.Tokens.DeleteByChain(ctx, *existing.ChainID)
|
||||
}
|
||||
return "", "", errx.Wrap(op, ErrTokenReused)
|
||||
}
|
||||
}
|
||||
return "", "", errx.Wrap(op, err)
|
||||
}
|
||||
|
||||
var chainID string
|
||||
if consumed.ChainID != nil {
|
||||
chainID = *consumed.ChainID
|
||||
}
|
||||
if chainID == "" {
|
||||
// Defensive: every refresh token should be chain-bound. Fall back
|
||||
// to a fresh chain so we never throw on missing metadata.
|
||||
chainID = uuid.NewString()
|
||||
}
|
||||
|
||||
access, err = a.signAccessToken(consumed.UserID, a.userSessionVersion(ctx, consumed.UserID))
|
||||
if err != nil {
|
||||
return "", "", errx.Wrap(op, err)
|
||||
}
|
||||
refresh, err = a.mintRefreshToken(ctx, consumed.UserID, chainID)
|
||||
if err != nil {
|
||||
return "", "", errx.Wrap(op, err)
|
||||
}
|
||||
return access, refresh, nil
|
||||
}
|
||||
|
||||
// mintRefreshToken stores a fresh refresh token bound to chainID and returns
|
||||
// the plaintext.
|
||||
func (a *Auth) mintRefreshToken(ctx context.Context, userID uuid.UUID, chainID string) (string, error) {
|
||||
const op = "authkit.Auth.mintRefreshToken"
|
||||
plaintext, hash, err := mintSecret(prefixRefresh, a.cfg.Random)
|
||||
if err != nil {
|
||||
return "", errx.Wrap(op, err)
|
||||
}
|
||||
now := a.now()
|
||||
t := &Token{
|
||||
Hash: hash,
|
||||
Kind: TokenRefresh,
|
||||
UserID: userID,
|
||||
ChainID: &chainID,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(a.cfg.RefreshTokenTTL),
|
||||
}
|
||||
if err := a.deps.Tokens.CreateToken(ctx, t); err != nil {
|
||||
return "", errx.Wrap(op, err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// userSessionVersion fetches the current session_version. Errors collapse to
|
||||
// 0 on the assumption that AuthenticateJWT will reject stale tokens cleanly
|
||||
// — but we still need a value to embed in the freshly-minted access token.
|
||||
func (a *Auth) userSessionVersion(ctx context.Context, userID uuid.UUID) int {
|
||||
if u, err := a.deps.Users.GetUserByID(ctx, userID); err == nil {
|
||||
return u.SessionVersion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue