Compare commits
15 Commits
428dc50aa1
...
491c9a824d
Author | SHA1 | Date | |
---|---|---|---|
491c9a824d | |||
476b9a13d9 | |||
42665fffbb | |||
a157a3ec0e | |||
024d07fdd6 | |||
23845e25dd | |||
47209c311c | |||
2caef38ce6 | |||
e88980e64f | |||
159e4ad0e2 | |||
6e2d67ad24 | |||
d46e296ce1 | |||
e98806e96f | |||
0ab82e2503 | |||
34c1ce7652 |
@ -8,8 +8,10 @@ ADMIN_NAME="admin"
|
|||||||
ADMIN_EMAIL="admin@test.net"
|
ADMIN_EMAIL="admin@test.net"
|
||||||
ADMIN_PASSWORD="secret"
|
ADMIN_PASSWORD="secret"
|
||||||
|
|
||||||
JWT_PRIVATE_KEY="ecdsa"
|
JWT_PRIVATE_KEY="rsa"
|
||||||
JWT_PUBLIC_KEY="ecdsa"
|
JWT_PUBLIC_KEY="rsa"
|
||||||
|
|
||||||
|
JWT_KID="my-rsa-key-1"
|
||||||
|
|
||||||
MINIO_ENDPOINT="localhost:9000"
|
MINIO_ENDPOINT="localhost:9000"
|
||||||
MINIO_ACCESS_KEY=""
|
MINIO_ACCESS_KEY=""
|
||||||
|
@ -38,10 +38,10 @@ func (s *APIServer) Run() error {
|
|||||||
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
||||||
// FileServer(router, "/static", staticDir)
|
// FileServer(router, "/static", staticDir)
|
||||||
|
|
||||||
oauthHandler := oauth.NewOAuthHandler()
|
oauthHandler := oauth.NewOAuthHandler(s.repo)
|
||||||
|
|
||||||
router.Route("/api/v1", func(r chi.Router) {
|
router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register", "/api/v1/oauth"))
|
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register", "/api/v1/oauth/token"))
|
||||||
|
|
||||||
userHandler := user.NewUserHandler(s.repo, s.storage)
|
userHandler := user.NewUserHandler(s.repo, s.storage)
|
||||||
userHandler.RegisterRoutes(r)
|
userHandler.RegisterRoutes(r)
|
||||||
@ -52,7 +52,8 @@ func (s *APIServer) Run() error {
|
|||||||
oauthHandler.RegisterRoutes(r)
|
oauthHandler.RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.Get("/.well-known/openid-configuration", oauthHandler.Metadata)
|
router.Get("/.well-known/jwks.json", auth.WriteJWKS)
|
||||||
|
router.Get("/.well-known/openid-configuration", auth.OpenIdConfiguration)
|
||||||
|
|
||||||
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := "./dist" + r.URL.Path
|
path := "./dist" + r.URL.Path
|
||||||
|
@ -1,17 +1,69 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ecdsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/types"
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseBase64PrivateKey(envVar string) (*ecdsa.PrivateKey, error) {
|
func WriteJWKS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pubKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY")
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "failed to parse public key", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes())
|
||||||
|
e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 = 0x010001
|
||||||
|
|
||||||
|
jwks := map[string]interface{}{
|
||||||
|
"keys": []map[string]string{
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"kid": "my-rsa-key-1",
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "RS256",
|
||||||
|
"n": n,
|
||||||
|
"e": e,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(jwks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenIdConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type Response struct {
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
|
JwksURI string `json:"jwks_uri"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
if err := encoder.Encode(Response{
|
||||||
|
TokenEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/api/v1/oauth/token",
|
||||||
|
AuthorizationEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/authorize",
|
||||||
|
JwksURI: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/.well-known/jwks.json",
|
||||||
|
Issuer: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app",
|
||||||
|
EndSessionEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/api/v1/oauth/logout",
|
||||||
|
}); err != nil {
|
||||||
|
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBase64PrivateKey(envVar string) (*rsa.PrivateKey, error) {
|
||||||
b64 := os.Getenv(envVar)
|
b64 := os.Getenv(envVar)
|
||||||
if b64 == "" {
|
if b64 == "" {
|
||||||
return nil, fmt.Errorf("env var %s is empty", envVar)
|
return nil, fmt.Errorf("env var %s is empty", envVar)
|
||||||
@ -22,10 +74,11 @@ func parseBase64PrivateKey(envVar string) (*ecdsa.PrivateKey, error) {
|
|||||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return x509.ParseECPrivateKey(decoded)
|
key, err := x509.ParsePKCS8PrivateKey(decoded)
|
||||||
|
return key.(*rsa.PrivateKey), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBase64PublicKey(envVar string) (*ecdsa.PublicKey, error) {
|
func parseBase64PublicKey(envVar string) (*rsa.PublicKey, error) {
|
||||||
b64 := os.Getenv(envVar)
|
b64 := os.Getenv(envVar)
|
||||||
if b64 == "" {
|
if b64 == "" {
|
||||||
return nil, fmt.Errorf("env var %s is empty", envVar)
|
return nil, fmt.Errorf("env var %s is empty", envVar)
|
||||||
@ -41,9 +94,9 @@ func parseBase64PublicKey(envVar string) (*ecdsa.PublicKey, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse public key: %v", err)
|
return nil, fmt.Errorf("failed to parse public key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey, ok := pubInterface.(*ecdsa.PublicKey)
|
pubKey, ok := pubInterface.(*rsa.PublicKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("not an ECDSA public key")
|
return nil, fmt.Errorf("not an RSA public key")
|
||||||
}
|
}
|
||||||
|
|
||||||
return pubKey, nil
|
return pubKey, nil
|
||||||
@ -55,7 +108,10 @@ func SignJwtToken(claims jwt.Claims) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
|
||||||
|
token.Header["kid"] = "my-rsa-key-1"
|
||||||
|
|
||||||
s, err := token.SignedString(privateKey)
|
s, err := token.SignedString(privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -72,7 +128,7 @@ func VerifyToken(token string) (*jwt.Token, *types.UserClaims, error) {
|
|||||||
|
|
||||||
claims := &types.UserClaims{}
|
claims := &types.UserClaims{}
|
||||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
}
|
}
|
||||||
return publicKey, nil
|
return publicKey, nil
|
||||||
@ -88,4 +144,3 @@ func VerifyToken(token string) (*jwt.Token, *types.UserClaims, error) {
|
|||||||
|
|
||||||
return parsed, claims, nil
|
return parsed, claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,6 +132,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Avatar
|
// Avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := encoder.Encode(Response{
|
if err := encoder.Encode(Response{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
|
@ -1,49 +1,210 @@
|
|||||||
package oauth
|
package oauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.local/admin/hspguard/internal/auth"
|
||||||
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
|
"gitea.local/admin/hspguard/internal/util"
|
||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuthHandler struct{}
|
type OAuthHandler struct {
|
||||||
|
repo *repository.Queries
|
||||||
|
}
|
||||||
|
|
||||||
func NewOAuthHandler() *OAuthHandler {
|
func NewOAuthHandler(repo *repository.Queries) *OAuthHandler {
|
||||||
return &OAuthHandler{}
|
return &OAuthHandler{
|
||||||
|
repo,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *OAuthHandler) RegisterRoutes(r chi.Router) {
|
func (h *OAuthHandler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/oauth/authorize", h.authorizeEndpoint)
|
r.Post("/oauth/token", h.tokenEndpoint)
|
||||||
r.Get("/oauth/token", h.tokenEndpoint)
|
|
||||||
|
r.Post("/oauth/code", h.getAuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Println("[OAUTH] New request to token endpoint")
|
userId, ok := util.GetRequestUserId(r.Context())
|
||||||
w.WriteHeader(http.StatusOK)
|
if !ok {
|
||||||
w.Write([]byte("OK"))
|
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (h *OAuthHandler) authorizeEndpoint(w http.ResponseWriter, r *http.Request) {
|
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||||
log.Println("[OAUTH] New request to authorize endpoint")
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusOK)
|
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||||
w.Write([]byte("OK"))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Request
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
if err := decoder.Decode(&req); err != nil {
|
||||||
|
web.Error(w, "nonce field is required in request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Create real authorization code
|
||||||
|
|
||||||
func (h *OAuthHandler) Metadata(w http.ResponseWriter, r *http.Request) {
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
TokenEndpoint string `json:"token_endpoint"`
|
Code string `json:"code"`
|
||||||
AuthEndpoint string `json:"authorization_endpoint"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := encoder.Encode(Response{
|
if err := encoder.Encode(Response{
|
||||||
TokenEndpoint: "http://192.168.178.21:3001/api/v1/oauth/token",
|
Code: fmt.Sprintf("%s,%s", user.ID.String(), req.Nonce),
|
||||||
AuthEndpoint: "http://192.168.178.21:5173/authorize",
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Println("[OAUTH] New request to token endpoint")
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode credentials
|
||||||
|
payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid auth encoding", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId string
|
||||||
|
var clientSecret string
|
||||||
|
|
||||||
|
parts := strings.SplitN(string(payload), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId = parts[0]
|
||||||
|
clientSecret = parts[1]
|
||||||
|
|
||||||
|
log.Printf("Some client is trying to exchange code with id: %s and secret: %s\n", clientId, clientSecret)
|
||||||
|
|
||||||
|
// Parse the form data
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grantType := r.FormValue("grant_type")
|
||||||
|
redirectUri := r.FormValue("redirect_uri")
|
||||||
|
|
||||||
|
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||||
|
|
||||||
|
switch grantType {
|
||||||
|
case "authorization_code":
|
||||||
|
code := r.FormValue("code")
|
||||||
|
|
||||||
|
fmt.Printf("Code received: %s\n", code)
|
||||||
|
|
||||||
|
// TODO: Verify code from another db table
|
||||||
|
nonce := strings.Split(code, ",")[1]
|
||||||
|
|
||||||
|
userId := strings.Split(code, ",")[0]
|
||||||
|
|
||||||
|
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "requested user not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := types.ApiClaims{
|
||||||
|
Email: user.Email,
|
||||||
|
// TODO:
|
||||||
|
EmailVerified: true,
|
||||||
|
Name: user.FullName,
|
||||||
|
Picture: user.ProfilePicture.String,
|
||||||
|
Nonce: nonce,
|
||||||
|
Roles: []string{"user", "admin"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app",
|
||||||
|
// TODO: use dedicated API id that is in local DB and bind to user there
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
Audience: jwt.ClaimStrings{clientId},
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := auth.SignJwtToken(claims)
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "failed to sign id token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
IdToken string `json:"id_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
// TODO: add expires_in, refresh_token, scope (RFC 8693 $2)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := Response{
|
||||||
|
IdToken: idToken,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
// FIXME:
|
||||||
|
AccessToken: idToken,
|
||||||
|
Email: user.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("sending following response: %#v\n", response)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
if err := encoder.Encode(response); err != nil {
|
||||||
|
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
web.Error(w, "unsupported grant type", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (h *OAuthHandler) Metadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// type Response struct {
|
||||||
|
// TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
// AuthEndpoint string `json:"authorization_endpoint"`
|
||||||
|
// Issuer string `json:"issuer"`
|
||||||
|
// JwksUri string `json:"jwks_uri"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// encoder := json.NewEncoder(w)
|
||||||
|
|
||||||
|
// if err := encoder.Encode(Response{
|
||||||
|
// TokenEndpoint: "http://192.168.178.21:3001/api/v1/oauth/token",
|
||||||
|
// AuthEndpoint: "https://147f-2a00-10-5b00-c801-4882-7ef0-5e68-d360.ngrok-free.app/authorize",
|
||||||
|
// JwksUri: "http://192.168.178.21:3001/.well-known/jwks.json",
|
||||||
|
// Issuer: "http://192.168.178.21:3001",
|
||||||
|
// }); err != nil {
|
||||||
|
// web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
@ -8,12 +8,12 @@ type UserClaims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ApiClaims struct {
|
type ApiClaims struct {
|
||||||
UserID string `json:"user_id"`
|
Email string `json:"email"`
|
||||||
// Permissions are guard's defined permissions
|
EmailVerified bool `json:"email_verified"`
|
||||||
// Examples:
|
Name string `json:"name"`
|
||||||
// 1. User MetaData (specifically some fields like email, profile picture and name)
|
Picture string `json:"picture"`
|
||||||
// 2. Actions on User, e.g. home permissions fetching, notifications emitting
|
Nonce string `json:"nonce"`
|
||||||
Permissions []string `json:"permissions"`
|
Roles []string `json:"roles"`
|
||||||
// Subject is an API ID defined in guard's DB after registration
|
// TODO: add given_name, family_name, locale...
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
# Generate private key
|
# Generate 2048-bit RSA private key (suppress output)
|
||||||
openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
|
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 *> $null
|
||||||
|
|
||||||
# Extract public key
|
# Extract the public key from the private key (suppress output)
|
||||||
openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
|
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem *> $null
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Private Key (DER base64):"
|
|
||||||
openssl ec -in ec256-private.pem -outform DER | openssl base64 -A
|
|
||||||
|
|
||||||
Write-Host ""
|
# Base64 encode private key (DER format, for JWT_PRIVATE_KEY)
|
||||||
Write-Host "--------------------------------"
|
Write-Host -NoNewline 'JWT_PRIVATE_KEY="'
|
||||||
Write-Host ""
|
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>$null | openssl base64 -A
|
||||||
|
Write-Host '"'
|
||||||
|
|
||||||
Write-Host "Public Key (DER base64):"
|
# Base64 encode public key (DER format, for JWT_PUBLIC_KEY)
|
||||||
openssl ec -in ec256-private.pem -pubout -outform DER | openssl base64 -A
|
Write-Host -NoNewline 'JWT_PUBLIC_KEY="'
|
||||||
|
openssl rsa -in rsa-private.pem -pubout -outform DER 2>$null | openssl base64 -A
|
||||||
|
Write-Host '"'
|
@ -1,26 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Generate private key
|
# Generate 2048-bit RSA private key (suppress all output)
|
||||||
# openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
|
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 >/dev/null 2>&1
|
||||||
# openssl ec -in ec256-private.pem -outform DER | base64 -w 0
|
|
||||||
|
|
||||||
# Extract public key
|
# Extract the public key from the private key (suppress all output)
|
||||||
# openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
|
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem >/dev/null 2>&1
|
||||||
# openssl ec -in ec256-private.pem -pubout -outform DER | base64 -w 0
|
|
||||||
|
|
||||||
# Generate private key
|
|
||||||
openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
|
|
||||||
|
|
||||||
# Extract public key
|
|
||||||
openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Private Key (DER base64):"
|
|
||||||
openssl ec -in ec256-private.pem -outform DER | base64 -w 0
|
|
||||||
|
|
||||||
echo "
|
# Base64 encode private key (for JWT_PRIVATE_KEY)
|
||||||
--------------------------------"
|
echo -n 'JWT_PRIVATE_KEY="'
|
||||||
|
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>/dev/null | base64 -w 0
|
||||||
|
echo '"'
|
||||||
|
|
||||||
echo ""
|
# Base64 encode public key (for JWT_PUBLIC_KEY)
|
||||||
echo "Public Key (DER base64):"
|
echo -n 'JWT_PUBLIC_KEY="'
|
||||||
openssl ec -in ec256-private.pem -pubout -outform DER | base64 -w 0
|
openssl rsa -in rsa-private.pem -pubout -outform DER 2>/dev/null | base64 -w 0
|
||||||
|
echo '"'
|
@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build --watch",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
25
web/src/api/code.ts
Normal file
25
web/src/api/code.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { handleApiError } from ".";
|
||||||
|
|
||||||
|
export interface CodeResponse {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const codeApi = async (accessToken: string, nonce: string) => {
|
||||||
|
const response = await fetch("/api/v1/oauth/code", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nonce,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200 && response.status !== 201)
|
||||||
|
throw await handleApiError(response);
|
||||||
|
|
||||||
|
const data: CodeResponse = await response.json();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
@ -13,6 +13,7 @@ interface OAuthContextValues {
|
|||||||
setScope: (scopes: string[]) => void;
|
setScope: (scopes: string[]) => void;
|
||||||
setState: (state: string) => void;
|
setState: (state: string) => void;
|
||||||
setNonce: (nonce: string) => void;
|
setNonce: (nonce: string) => void;
|
||||||
|
selectSession: (token: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OAuthContext = createContext<OAuthContextValues>({
|
export const OAuthContext = createContext<OAuthContextValues>({
|
||||||
@ -28,6 +29,7 @@ export const OAuthContext = createContext<OAuthContextValues>({
|
|||||||
setScope: () => {},
|
setScope: () => {},
|
||||||
setState: () => {},
|
setState: () => {},
|
||||||
setNonce: () => {},
|
setNonce: () => {},
|
||||||
|
selectSession: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useOAuthContext = () => useContext(OAuthContext);
|
export const useOAuthContext = () => useContext(OAuthContext);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useState, type FC, type ReactNode } from "react";
|
import { useCallback, useState, type FC, type ReactNode } from "react";
|
||||||
import { OAuthContext } from ".";
|
import { OAuthContext } from ".";
|
||||||
|
import { codeApi } from "@/api/code";
|
||||||
|
|
||||||
interface IOAuthProvider {
|
interface IOAuthProvider {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -13,6 +14,24 @@ export const OAuthProvider: FC<IOAuthProvider> = ({ children }) => {
|
|||||||
const [state, setState] = useState("");
|
const [state, setState] = useState("");
|
||||||
const [nonce, setNonce] = useState("");
|
const [nonce, setNonce] = useState("");
|
||||||
|
|
||||||
|
const selectSession = useCallback(
|
||||||
|
async (token: string) => {
|
||||||
|
if (active && redirectURI) {
|
||||||
|
const codeResponse = await codeApi(token, nonce);
|
||||||
|
|
||||||
|
console.log("gen code:", { codeResponse });
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
code: codeResponse.code,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.replace(`${redirectURI}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[active, nonce, redirectURI, state]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OAuthContext.Provider
|
<OAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -28,6 +47,7 @@ export const OAuthProvider: FC<IOAuthProvider> = ({ children }) => {
|
|||||||
setScope,
|
setScope,
|
||||||
setState,
|
setState,
|
||||||
setNonce,
|
setNonce,
|
||||||
|
selectSession,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { useDbContext } from "@/context/db";
|
import { useDbContext } from "@/context/db";
|
||||||
|
import { useOAuthContext } from "@/context/oauth";
|
||||||
import { type LocalAccount, useAccountRepo } from "@/repository/account";
|
import { type LocalAccount, useAccountRepo } from "@/repository/account";
|
||||||
import { CirclePlus, User } from "lucide-react";
|
import { CirclePlus, User } from "lucide-react";
|
||||||
import { useEffect, useState, type FC } from "react";
|
import { useCallback, useEffect, useState, type FC } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
const AccountList: FC = () => {
|
const AccountList: FC = () => {
|
||||||
@ -10,6 +11,15 @@ const AccountList: FC = () => {
|
|||||||
const repo = useAccountRepo();
|
const repo = useAccountRepo();
|
||||||
const { connected } = useDbContext();
|
const { connected } = useDbContext();
|
||||||
|
|
||||||
|
const oauth = useOAuthContext();
|
||||||
|
|
||||||
|
const handleAccountSelect = useCallback(
|
||||||
|
(account: LocalAccount) => {
|
||||||
|
oauth.selectSession(account.access);
|
||||||
|
},
|
||||||
|
[oauth]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected) repo.loadAll().then(setAccounts);
|
if (connected) repo.loadAll().then(setAccounts);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -46,6 +56,7 @@ const AccountList: FC = () => {
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.accountId}
|
key={account.accountId}
|
||||||
|
onClick={() => handleAccountSelect(account)}
|
||||||
className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0"
|
className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
@ -9,6 +9,7 @@ import { useForm, type SubmitHandler } from "react-hook-form";
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { loginApi } from "@/api/login";
|
import { loginApi } from "@/api/login";
|
||||||
import { useAccountRepo } from "@/repository/account";
|
import { useAccountRepo } from "@/repository/account";
|
||||||
|
import { useOAuthContext } from "@/context/oauth";
|
||||||
|
|
||||||
interface LoginForm {
|
interface LoginForm {
|
||||||
email: string;
|
email: string;
|
||||||
@ -27,6 +28,8 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const oauth = useOAuthContext();
|
||||||
|
|
||||||
const repo = useAccountRepo();
|
const repo = useAccountRepo();
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<LoginForm> = useCallback(
|
const onSubmit: SubmitHandler<LoginForm> = useCallback(
|
||||||
@ -56,6 +59,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
setSuccess("You have successfully logged in");
|
setSuccess("You have successfully logged in");
|
||||||
reset();
|
reset();
|
||||||
|
|
||||||
|
oauth.selectSession(response.access);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
setError(
|
setError(
|
||||||
@ -66,7 +71,7 @@ export default function LoginPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[repo, reset]
|
[oauth, repo, reset]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
import { deriveDeviceKey, getDeviceId } from "./deviceId";
|
|
||||||
import { encryptToken } from "./token";
|
|
||||||
|
|
||||||
const storeTokensForAccount = async (
|
|
||||||
accountId: string,
|
|
||||||
accessToken: string,
|
|
||||||
refreshToken: string
|
|
||||||
) => {
|
|
||||||
const deviceKeyId = await getDeviceId();
|
|
||||||
const key = await deriveDeviceKey(deviceKeyId);
|
|
||||||
|
|
||||||
const access = await encryptToken(accessToken, key);
|
|
||||||
const refresh = await encryptToken(refreshToken, key);
|
|
||||||
|
|
||||||
const entry = {
|
|
||||||
accountId,
|
|
||||||
label: `Account for ${accountId}`,
|
|
||||||
access: {
|
|
||||||
data: Array.from(new Uint8Array(access.cipherText)),
|
|
||||||
iv: Array.from(access.iv),
|
|
||||||
},
|
|
||||||
refresh: {
|
|
||||||
data: Array.from(new Uint8Array(refresh.cipherText)),
|
|
||||||
iv: Array.from(refresh.iv),
|
|
||||||
},
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save this `entry` in IndexedDB (or use a localforage wrapper)
|
|
||||||
};
|
|
Reference in New Issue
Block a user