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 cfg *config.AppConfig
} }
type SignedToken struct { func (h *AuthHandler) signTokens(user *repository.User) (*types.SignedToken, *types.SignedToken, error) {
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) {
accessExpiresAt := time.Now().Add(15 * time.Minute) accessExpiresAt := time.Now().Add(15 * time.Minute)
accessJTI := uuid.New() accessJTI := uuid.New()
@ -75,7 +61,7 @@ func (h *AuthHandler) signTokens(user *repository.User) (*SignedToken, *SignedTo
return nil, nil, err 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 { func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {

View File

@ -17,20 +17,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type ApiToken struct { func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*types.SignedToken, *types.SignedToken, *types.SignedToken, error) {
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) {
accessExpiresIn := 15 * time.Minute accessExpiresIn := 15 * time.Minute
accessExpiresAt := time.Now().Add(accessExpiresIn) accessExpiresAt := time.Now().Add(accessExpiresIn)
accessJTI := uuid.New()
accessClaims := types.ApiClaims{ accessClaims := types.ApiClaims{
Permissions: []string{}, Permissions: []string{},
@ -40,12 +30,13 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
Audience: jwt.ClaimStrings{apiService.ClientID}, Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(accessExpiresAt), ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
ID: accessJTI.String(),
}, },
} }
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey) access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return nil, err return nil, nil, nil, err
} }
var roles = []string{"user"} var roles = []string{"user"}
@ -56,6 +47,7 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
idExpiresIn := 15 * time.Minute idExpiresIn := 15 * time.Minute
idExpiresAt := time.Now().Add(idExpiresIn) idExpiresAt := time.Now().Add(idExpiresIn)
idJTI := uuid.New()
idClaims := types.IdTokenClaims{ idClaims := types.IdTokenClaims{
Email: user.Email, Email: user.Email,
@ -70,16 +62,18 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
Audience: jwt.ClaimStrings{apiService.ClientID}, Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(idExpiresAt), ExpiresAt: jwt.NewNumericDate(idExpiresAt),
ID: idJTI.String(),
}, },
} }
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey) idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return nil, err return nil, nil, nil, err
} }
refreshExpiresIn := 24 * time.Hour refreshExpiresIn := 24 * time.Hour
refreshExpiresAt := time.Now().Add(refreshExpiresIn) refreshExpiresAt := time.Now().Add(refreshExpiresIn)
refreshJTI := uuid.New()
refreshClaims := types.ApiRefreshClaims{ refreshClaims := types.ApiRefreshClaims{
UserID: user.ID.String(), UserID: user.ID.String(),
@ -89,28 +83,16 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito
Audience: jwt.ClaimStrings{apiService.ClientID}, Audience: jwt.ClaimStrings{apiService.ClientID},
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
ID: refreshJTI.String(),
}, },
} }
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey) refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return nil, err return nil, nil, nil, err
} }
return &ApiTokens{ return types.NewSignedToken(idToken, idExpiresAt, idJTI), types.NewSignedToken(access, accessExpiresAt, accessJTI), types.NewSignedToken(refresh, refreshExpiresAt, refreshJTI), nil
ID: ApiToken{
Token: idToken,
Expiration: idExpiresIn.Seconds(),
},
Access: ApiToken{
Token: access,
Expiration: accessExpiresIn.Seconds(),
},
Refresh: ApiToken{
Token: refresh,
Expiration: refreshExpiresIn.Seconds(),
},
}, nil
} }
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { 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) 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 { if err != nil {
log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err) 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) web.Error(w, "no session found under this auth code", http.StatusNotFound)
return 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 { 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) web.Error(w, "service is not registered", http.StatusForbidden)
return return
} }
if session.ClientID != clientId { if codeSession.ClientID != clientId {
web.Error(w, "invalid auth", http.StatusUnauthorized) web.Error(w, "invalid auth", http.StatusUnauthorized)
return 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 { if err != nil {
web.Error(w, "requested user not found", http.StatusNotFound) web.Error(w, "requested user not found", http.StatusNotFound)
return return
} }
tokens, err := h.signApiTokens(&user, &apiService, &session.Nonce) id, access, refresh, err := h.signApiTokens(&user, &apiService, &codeSession.Nonce)
if err != nil { if err != nil {
log.Println("ERR: Failed to sign api tokens:", err) log.Println("ERR: Failed to sign api tokens:", err)
web.Error(w, "failed to sign tokens", http.StatusInternalServerError) web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
return 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 { type Response struct {
IdToken string `json:"id_token"` IdToken string `json:"id_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
@ -219,11 +232,11 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
} }
response := Response{ response := Response{
IdToken: tokens.ID.Token, IdToken: id.Token,
TokenType: "Bearer", TokenType: "Bearer",
AccessToken: tokens.Access.Token, AccessToken: access.Token,
RefreshToken: tokens.Refresh.Token, RefreshToken: refresh.Token,
ExpiresIn: tokens.Access.Expiration, ExpiresIn: access.ExpiresAt.Sub(time.Now()).Seconds(),
Email: user.Email, Email: user.Email,
} }
@ -269,7 +282,7 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
return return
} }
tokens, err := h.signApiTokens(&user, &apiService, nil) id, access, refresh, err := h.signApiTokens(&user, &apiService, nil)
type Response struct { type Response struct {
IdToken string `json:"id_token"` IdToken string `json:"id_token"`
@ -280,11 +293,11 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
} }
response := Response{ response := Response{
IdToken: tokens.ID.Token, IdToken: id.Token,
TokenType: "Bearer", TokenType: "Bearer",
AccessToken: tokens.Access.Token, AccessToken: access.Token,
RefreshToken: tokens.Refresh.Token, RefreshToken: refresh.Token,
ExpiresIn: tokens.Access.Expiration, ExpiresIn: access.ExpiresAt.Sub(time.Now()).Seconds(),
} }
log.Printf("DEBUG: refresh - sending following response: %#v\n", response) log.Printf("DEBUG: refresh - sending following response: %#v\n", response)

View File

@ -20,7 +20,7 @@ INSERT INTO service_sessions (
) VALUES ( ) VALUES (
$1, $2, $3, NOW(), $4, $5, $1, $2, $3, NOW(), $4, $5,
$6, $7, $8, $9, $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 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"` UserAgent *string `json:"user_agent"`
AccessTokenID *uuid.UUID `json:"access_token_id"` AccessTokenID *uuid.UUID `json:"access_token_id"`
RefreshTokenID *uuid.UUID `json:"refresh_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) { 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.UserAgent,
arg.AccessTokenID, arg.AccessTokenID,
arg.RefreshTokenID, arg.RefreshTokenID,
arg.Scope,
arg.Claims,
) )
var i ServiceSession var i ServiceSession
err := row.Scan( 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 ( ) VALUES (
$1, $2, $3, NOW(), $4, $5, $1, $2, $3, NOW(), $4, $5,
$6, $7, $8, $9, $6, $7, $8, $9,
TRUE, $8, $9 TRUE, $10, $11
) )
RETURNING *; RETURNING *;

View File

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

View File

@ -71,7 +71,7 @@ const AdminServiceSessionsPage: FC = () => {
Service Service
</th> </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"> <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>
<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"> <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 Status
@ -109,26 +109,44 @@ const AdminServiceSessionsPage: FC = () => {
key={session.id} key={session.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800" 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"> <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.} /> */} {/* <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>
<td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700"> <td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700">
<span <span

View File

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