Compare commits

..

8 Commits

Author SHA1 Message Date
a773f1f8b4 feat: signed token type 2025-06-15 21:05:09 +02:00
1a71f50914 fix: 'boolean' instead of 'bool' 2025-06-15 21:05:03 +02:00
d17e154e42 feat: logical columns for service sessions table 2025-06-15 21:04:50 +02:00
bad26775eb fix: don't redirect due to credentials modal 2025-06-15 21:04:40 +02:00
c3fd6637a5 fix: numeration 2025-06-15 21:04:22 +02:00
20173ea140 fix: numeration 2025-06-15 21:04:07 +02:00
41d439beab feat: create service session 2025-06-15 21:02:38 +02:00
b36b6e18ca feat: use signed token from types 2025-06-15 21:02:22 +02:00
8 changed files with 131 additions and 91 deletions

View File

@ -20,21 +20,7 @@ type AuthHandler struct {
cfg *config.AppConfig
}
type SignedToken struct {
Token string
ExpiresAt time.Time
ID uuid.UUID
}
func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken {
return &SignedToken{
Token: token,
ExpiresAt: expiresAt,
ID: jti,
}
}
func (h *AuthHandler) signTokens(user *repository.User) (*SignedToken, *SignedToken, error) {
func (h *AuthHandler) signTokens(user *repository.User) (*types.SignedToken, *types.SignedToken, error) {
accessExpiresAt := time.Now().Add(15 * time.Minute)
accessJTI := uuid.New()
@ -75,7 +61,7 @@ func (h *AuthHandler) signTokens(user *repository.User) (*SignedToken, *SignedTo
return nil, nil, err
}
return NewSignedToken(accessToken, accessExpiresAt, accessJTI), NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil
return types.NewSignedToken(accessToken, accessExpiresAt, accessJTI), types.NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil
}
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {

View File

@ -17,20 +17,10 @@ import (
"github.com/google/uuid"
)
type ApiToken struct {
Token string
Expiration float64
}
type ApiTokens struct {
ID ApiToken
Access ApiToken
Refresh ApiToken
}
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*ApiTokens, error) {
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*types.SignedToken, *types.SignedToken, *types.SignedToken, error) {
accessExpiresIn := 15 * time.Minute
accessExpiresAt := time.Now().Add(accessExpiresIn)
accessJTI := uuid.New()
accessClaims := types.ApiClaims{
Permissions: []string{},
@ -40,12 +30,13 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
ID: accessJTI.String(),
},
}
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return nil, err
return nil, nil, nil, err
}
var roles = []string{"user"}
@ -56,6 +47,7 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
idExpiresIn := 15 * time.Minute
idExpiresAt := time.Now().Add(idExpiresIn)
idJTI := uuid.New()
idClaims := types.IdTokenClaims{
Email: user.Email,
@ -70,16 +62,18 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(idExpiresAt),
ID: idJTI.String(),
},
}
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return nil, err
return nil, nil, nil, err
}
refreshExpiresIn := 24 * time.Hour
refreshExpiresAt := time.Now().Add(refreshExpiresIn)
refreshJTI := uuid.New()
refreshClaims := types.ApiRefreshClaims{
UserID: user.ID.String(),
@ -89,28 +83,16 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
ID: refreshJTI.String(),
},
}
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return nil, err
return nil, nil, nil, err
}
return &ApiTokens{
ID: ApiToken{
Token: idToken,
Expiration: idExpiresIn.Seconds(),
},
Access: ApiToken{
Token: access,
Expiration: accessExpiresIn.Seconds(),
},
Refresh: ApiToken{
Token: refresh,
Expiration: refreshExpiresIn.Seconds(),
},
}, nil
return types.NewSignedToken(idToken, idExpiresAt, idJTI), types.NewSignedToken(access, accessExpiresAt, accessJTI), types.NewSignedToken(refresh, refreshExpiresAt, refreshJTI), nil
}
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
@ -174,40 +156,71 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Code received: %s\n", code)
session, err := h.cache.GetAuthCode(r.Context(), code)
codeSession, err := h.cache.GetAuthCode(r.Context(), code)
if err != nil {
log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err)
web.Error(w, "no session found under this auth code", http.StatusNotFound)
return
}
log.Printf("DEBUG: Fetched code session: %#v\n", session)
log.Printf("DEBUG: Fetched code session: %#v\n", codeSession)
apiService, err := h.repo.GetApiServiceCID(r.Context(), session.ClientID)
apiService, err := h.repo.GetApiServiceCID(r.Context(), codeSession.ClientID)
if err != nil {
log.Printf("ERR: Could not find API service with client %s: %v\n", session.ClientID, err)
log.Printf("ERR: Could not find API service with client %s: %v\n", codeSession.ClientID, err)
web.Error(w, "service is not registered", http.StatusForbidden)
return
}
if session.ClientID != clientId {
if codeSession.ClientID != clientId {
web.Error(w, "invalid auth", http.StatusUnauthorized)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(session.UserID))
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(codeSession.UserID))
if err != nil {
web.Error(w, "requested user not found", http.StatusNotFound)
return
}
tokens, err := h.signApiTokens(&user, &apiService, &session.Nonce)
id, access, refresh, err := h.signApiTokens(&user, &apiService, &codeSession.Nonce)
if err != nil {
log.Println("ERR: Failed to sign api tokens:", err)
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
return
}
log.Printf("DEBUG: Created api tokens: %v\n\n%v\n\n%v\n", id.ID.String(), access.ID.String(), refresh.ID.String())
userId, err := uuid.Parse(codeSession.UserID)
if err != nil {
log.Printf("ERR: Failed to parse user '%s' uuid: %v\n", codeSession.UserID, err)
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
return
}
ipAddr := util.GetClientIP(r)
ua := r.UserAgent()
session, err := h.repo.CreateServiceSession(r.Context(), repository.CreateServiceSessionParams{
ServiceID: apiService.ID,
ClientID: apiService.ClientID,
UserID: &userId,
ExpiresAt: &refresh.ExpiresAt,
LastActive: nil,
IpAddress: &ipAddr,
UserAgent: &ua,
AccessTokenID: &access.ID,
RefreshTokenID: &refresh.ID,
})
if err != nil {
log.Printf("ERR: Failed to create new service session: %v\n", err)
web.Error(w, "failed to create session", http.StatusInternalServerError)
return
}
log.Printf("INFO: Service session created for '%s' client_id with '%s' id\n", apiService.ClientID, session.ID.String())
type Response struct {
IdToken string `json:"id_token"`
TokenType string `json:"token_type"`
@ -219,11 +232,11 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
}
response := Response{
IdToken: tokens.ID.Token,
IdToken: id.Token,
TokenType: "Bearer",
AccessToken: tokens.Access.Token,
RefreshToken: tokens.Refresh.Token,
ExpiresIn: tokens.Access.Expiration,
AccessToken: access.Token,
RefreshToken: refresh.Token,
ExpiresIn: access.ExpiresAt.Sub(time.Now()).Seconds(),
Email: user.Email,
}
@ -269,7 +282,7 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
return
}
tokens, err := h.signApiTokens(&user, &apiService, nil)
id, access, refresh, err := h.signApiTokens(&user, &apiService, nil)
type Response struct {
IdToken string `json:"id_token"`
@ -280,11 +293,11 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
}
response := Response{
IdToken: tokens.ID.Token,
IdToken: id.Token,
TokenType: "Bearer",
AccessToken: tokens.Access.Token,
RefreshToken: tokens.Refresh.Token,
ExpiresIn: tokens.Access.Expiration,
AccessToken: access.Token,
RefreshToken: refresh.Token,
ExpiresIn: access.ExpiresAt.Sub(time.Now()).Seconds(),
}
log.Printf("DEBUG: refresh - sending following response: %#v\n", response)

View File

@ -20,7 +20,7 @@ INSERT INTO service_sessions (
) VALUES (
$1, $2, $3, NOW(), $4, $5,
$6, $7, $8, $9,
TRUE, $8, $9
TRUE, $10, $11
)
RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims
`
@ -35,6 +35,8 @@ type CreateServiceSessionParams struct {
UserAgent *string `json:"user_agent"`
AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
Scope *string `json:"scope"`
Claims []byte `json:"claims"`
}
func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) {
@ -48,6 +50,8 @@ func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSes
arg.UserAgent,
arg.AccessTokenID,
arg.RefreshTokenID,
arg.Scope,
arg.Claims,
)
var i ServiceSession
err := row.Scan(

21
internal/types/token.go Normal file
View File

@ -0,0 +1,21 @@
package types
import (
"time"
"github.com/google/uuid"
)
type SignedToken struct {
Token string
ExpiresAt time.Time
ID uuid.UUID
}
func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken {
return &SignedToken{
Token: token,
ExpiresAt: expiresAt,
ID: jti,
}
}

View File

@ -6,7 +6,7 @@ INSERT INTO service_sessions (
) VALUES (
$1, $2, $3, NOW(), $4, $5,
$6, $7, $8, $9,
TRUE, $8, $9
TRUE, $10, $11
)
RETURNING *;

View File

@ -5,7 +5,7 @@ import ApiServiceCredentialsModal from "@/feature/ApiServiceCredentialsModal";
import { useApiServices } from "@/store/admin/apiServices";
import { useCallback, type FC } from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { Link } from "react-router";
interface FormData {
name: string;
@ -32,12 +32,10 @@ const ApiServiceCreatePage: FC = () => {
const credentials = useApiServices((state) => state.createdCredentials);
const navigate = useNavigate();
const onSubmit = useCallback(
async (data: FormData) => {
console.log("Form submitted:", data);
const success = await createApiService({
await createApiService({
name: data.name,
description: data.description ?? "",
redirect_uris: data.redirectUris.trim().split("\n"),
@ -47,11 +45,11 @@ const ApiServiceCreatePage: FC = () => {
: ["authorization_code"],
is_active: data.enabled,
});
if (success) {
navigate("/admin/api-services");
}
// if (success) {
// navigate("/admin/api-services");
// }
},
[createApiService, navigate],
[createApiService],
);
return (

View File

@ -71,7 +71,7 @@ const AdminServiceSessionsPage: FC = () => {
Service
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Source
User + IP
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
Status
@ -109,26 +109,44 @@ const AdminServiceSessionsPage: FC = () => {
key={session.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
<div className="flex flex-row items-center gap-2 justify-start">
{typeof session.user?.profile_picture === "string" && (
<Avatar
avatarId={session.user.profile_picture}
className="w-7 h-7 min-w-7"
/>
)}
<Link to={`/admin/users/view/${session.user_id}`}>
<p className="cursor-pointer text-blue-500 text-nowrap">
{session.user?.full_name ?? ""}{" "}
{session.user_id === profile?.id ? "(You)" : ""}
</p>
</Link>
</div>
</td>
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
{/* <SessionSource deviceInfo={session.} /> */}
<p>{session.client_id}</p>
{typeof session.api_service?.icon_url === "string" && (
<Avatar
avatarId={session.api_service.icon_url}
className="w-7 h-7 min-w-7"
/>
)}
<Link to={`/admin/api-services/view/${session.service_id}`}>
<p className="cursor-pointer text-blue-500 text-nowrap">
{session.api_service?.name ?? session.client_id}
</p>
</Link>
</td>
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
<div className="flex flex-col items-stretch gap-2 justify-start">
<div className="flex flex-row items-center gap-2 justify-start">
{typeof session.user?.profile_picture === "string" && (
<Avatar
avatarId={session.user.profile_picture}
className="w-7 h-7 min-w-7"
/>
)}
<div className="flex flex-col items-stretch justify-center">
<Link to={`/admin/users/view/${session.user_id}`}>
<p className="cursor-pointer text-blue-500 text-nowrap">
{session.user?.full_name ?? ""}{" "}
{session.user_id === profile?.id ? "(You)" : ""}
</p>
</Link>
<p className="opacity-75">
{session.ip_address ?? "No IP available"}
</p>
</div>
</div>
</div>
</td>
<td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700">
<span

View File

@ -22,7 +22,7 @@ interface IApiServicesState {
fetch: () => Promise<void>;
fetchSingle: (id: string) => Promise<void>;
create: (req: CreateApiServiceRequest) => Promise<bool>;
create: (req: CreateApiServiceRequest) => Promise<boolean>;
resetCredentials: () => void;
toggling: boolean;