Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/features/accounts/account-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
mintKeysQuery,
seedQuery,
} from '../shared/cashu';
import { cashuAuthStore } from '../shared/cashu-mint-authentication';
import { useEncryption } from '../shared/encryption';
import type { Account } from './account';

Expand Down Expand Up @@ -208,12 +209,25 @@ export class AccountRepository {
);
}

const getClearAuthToken = async () => {
const token = await cashuAuthStore
.getState()
.getClearAuthTokenWithRefresh(mintUrl);
if (!token) {
throw new Error(`No clear auth token available for mint ${mintUrl}`);
}
return token;
};

const wallet = getExtendedCashuWallet(mintUrl, {
unit: getCashuUnit(currency),
bip39seed: seed ?? undefined,
mintInfo,
keys: activeKeysForUnit,
keysets: unitKeysets,
getClearAuthToken,
getBlindAuthToken: async () =>
cashuAuthStore.getState().getAndConsumeBlindAuthToken(mintUrl),
});

// The constructor does not set the keysetId, so we need to set it manually
Expand Down
4 changes: 4 additions & 0 deletions app/features/receive/receive-cashu-token-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export function useCashuTokenWithClaimableProofs({
cashuPubKey ? [cashuPubKey] : [],
);

// TODO: to determine if we can claim the token, we will need to check the auth requirements.
// Check if the swapping, or any melt operations require auth
// Then when receiving the token we will need to redirect the user to login to the mint if they don't have an auth session

return claimableProofs
? {
claimableToken: { ...token, proofs: claimableProofs },
Expand Down
4 changes: 4 additions & 0 deletions app/features/receive/receive-cashu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function MintQuoteCarouselItem({
error,
} = useCreateCashuReceiveQuote();

// TODO: we should not allow the user to create a quote if any minting endpoints require auth.
// For example, if creating a quote does not require auth, but minting does, we should not allow the user to create a quote
// because then they will pay the quote but be unable to mint.

const { quote, status: quotePaymentStatus } = useCashuReceiveQuote({
quoteId: createdQuote?.id,
onPaid: onPaid,
Expand Down
12 changes: 12 additions & 0 deletions app/features/settings/accounts/add-mint-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
cashuMintValidator,
mintInfoQuery,
} from '~/features/shared/cashu';
import { cashuAuthStore } from '~/features/shared/cashu-mint-authentication';
import { useUser } from '~/features/user/user-hooks';
import { useToast } from '~/hooks/use-toast';
import { getCashuProtocolUnit } from '~/lib/cashu';
Expand Down Expand Up @@ -54,6 +55,7 @@ export function AddMintForm() {
const defaultCurrency = useUser((u) => u.defaultCurrency);
const location = useLocation();
const queryClient = useQueryClient();
const { startOidcFlow, sessions: clearAuthSessions } = cashuAuthStore();

const {
register,
Expand All @@ -68,6 +70,16 @@ export function AddMintForm() {
});

const onSubmit = async (data: FormValues) => {
const mintInfo = await queryClient.fetchQuery(mintInfoQuery(data.mintUrl));
if (mintInfo.isSupported(21)) {
if (!clearAuthSessions[data.mintUrl]) {
// TODO: we should prompt the user before starting the oidc flow
// TODO: we should also maintain the form values so when we redirect back to here we can auto-submit the form
startOidcFlow(data.mintUrl);
return;
}
}

try {
await addAccount({
name: data.name,
Expand Down
233 changes: 233 additions & 0 deletions app/features/shared/cashu-mint-authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import {
CashuAuthMint,
CashuAuthWallet,
type Proof,
getEncodedAuthToken,
} from '@cashu/cashu-ts';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
completeOidcFlow,
prepareOidcFlow,
refreshOidcSession,
} from '~/lib/oidc-client';
import { getQueryClient } from '~/query-client';
import { mintInfoQuery } from './cashu';

/**
* Clear Authentication Session for NUT-21
* Contains OAuth 2.0 tokens and metadata from an OIDC provider
*/
export type ClearAuthSession = {
/** The access token (JWT) */
accessToken: string;
/** The refresh token for obtaining new access tokens */
refreshToken?: string;
/** When the access token expires (timestamp in seconds) */
expiresAt?: number;
/** The mint URL this token is associated with */
mintUrl: string;
/** OIDC authority that issued the token */
authority: string;
/** Client ID used for authentication */
clientId: string;
};

type CashuAuthStoreState = {
/**
* A map of mint URLs to their associated authentication sessions.
* The key is the mint URL, and the value is the authentication session.
*/
sessions: Record<string, ClearAuthSession>;
blindAuthTokens: Record<string, Proof[]>;
};

type CashuAuthStoreActions = {
startOidcFlow: (mintUrl: string) => Promise<void>;
completeOidcFlow: (callbackUrl: string) => Promise<void>;
getClearAuthTokenWithRefresh: (
mintUrl: string,
) => Promise<string | undefined>;
getAndConsumeBlindAuthToken: (mintUrl: string) => Promise<string>;
topUpBlindAuthTokens: (mintUrl: string) => Promise<void>;
};

export type CashuAuthStore = CashuAuthStoreState & CashuAuthStoreActions;

export const cashuAuthStore = create<CashuAuthStore>()(
persist(
(set, get) => ({
sessions: {},
blindAuthTokens: {},
startOidcFlow: async (mintUrl) => {
// TODO: casn we inject the query client here?
const mintInfo = await getQueryClient().fetchQuery(
mintInfoQuery(mintUrl),
);

const nut21 = mintInfo.isSupported(21);
if (!nut21.supported) {
throw new Error(
'Mint does not support clear authentication via NUT-21',
);
}

const alreadyHasSession = mintUrl in get().sessions;
if (alreadyHasSession) {
throw new Error('Session already exists for this mint');
}

const authUrl = await prepareOidcFlow({
openIdDiscoveryUrl: nut21.openid_discovery,
clientId: nut21.client_id,
redirectUri: `${window.location.origin}/oidc-callback`,
customStateData: {
mintUrl,
},
});

// Redirect to the authorization URL
// TODO: should we instead just return the authUrl and handle redirect in the component?
window.location.href = authUrl.toString();
},
completeOidcFlow: async (callbackUrl) => {
const session = await completeOidcFlow(callbackUrl);

const mintUrl = session.customStateData?.mintUrl;
if (!mintUrl || typeof mintUrl !== 'string') {
throw new Error('Mint URL not found in callback URL');
}

// Store the session with the mint URL
set((state) => ({
sessions: {
...state.sessions,
[mintUrl]: {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
expiresAt: session.expiresAt,
authority: session.authority,
clientId: session.clientId,
mintUrl,
},
},
}));
},
getClearAuthTokenWithRefresh: async (mintUrl) => {
const session = get().sessions[mintUrl];
if (!session) {
// TODO: we should ask the user before starting the oidc flow
await get().startOidcFlow(mintUrl);
return undefined;
}

if (!isSessionExpired(session)) {
return session.accessToken;
}

if (!session.refreshToken) {
throw new Error(
`Clear auth session for mint ${mintUrl} is expired and no refresh token is available`,
);
}

try {
// TODO: what if this fails? It could fail if the user's auth session has been revoked completely.
const refreshedSession = await refreshOidcSession({
authority: session.authority,
clientId: session.clientId,
refreshToken: session.refreshToken,
});

set((state) => ({
sessions: {
...state.sessions,
[mintUrl]: {
refreshToken: refreshedSession.refreshToken,
accessToken: refreshedSession.accessToken,
expiresAt: refreshedSession.expiresAt,
authority: session.authority,
clientId: session.clientId,
mintUrl,
},
},
}));

return refreshedSession.accessToken;
} catch (error) {
console.error('Error refreshing clear auth token', error);
//clear session
set((state) => {
delete state.sessions[mintUrl];
return state;
});
throw error;
}
},
topUpBlindAuthTokens: async (mintUrl) => {
const clearAuthToken =
await get().getClearAuthTokenWithRefresh(mintUrl);
if (!clearAuthToken) {
throw new Error(`No clear auth token available for mint ${mintUrl}`);
}

const authWallet = new CashuAuthWallet(new CashuAuthMint(mintUrl));

// TODO: read bat_max_mint from mint info
const NUM_TOKENS_TO_MINT = 30;
const blindAuthTokens = await authWallet.mintProofs(
NUM_TOKENS_TO_MINT,
clearAuthToken,
);

set((state) => ({
blindAuthTokens: {
...state.blindAuthTokens,
[mintUrl]: blindAuthTokens,
},
}));
},
getAndConsumeBlindAuthToken: async (mintUrl) => {
let blindAuthTokens = get().blindAuthTokens[mintUrl] || [];
if (blindAuthTokens.length === 0) {
await get().topUpBlindAuthTokens(mintUrl);
// Get fresh state after topping up
blindAuthTokens = get().blindAuthTokens[mintUrl] || [];
}

if (blindAuthTokens.length === 0) {
throw new Error(`No blind auth tokens available for mint ${mintUrl}`);
}

// Get the token before removing it
const token = blindAuthTokens[0];

// Remove the consumed token from the store
set((state) => ({
blindAuthTokens: {
...state.blindAuthTokens,
[mintUrl]: blindAuthTokens.slice(1),
},
}));

return getEncodedAuthToken(token);
},
}),
{
name: 'cashu-auth-store',
partialize: (state) => ({
sessions: state.sessions,
blindAuthTokens: state.blindAuthTokens,
}),
},
),
);

const isSessionExpired = (session: ClearAuthSession) => {
if (!session.expiresAt) {
return false;
}
const now = Date.now() / 1000; // current time in seconds
const bufferTime = 5; // 5 seconds buffer time
return session.expiresAt < now + bufferTime;
};
17 changes: 11 additions & 6 deletions app/lib/cashu/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,17 +201,22 @@ export const getExtendedCashuWallet = (
> & {
unit?: CurrencyUnit;
bip39seed: Uint8Array;
getClearAuthToken?: () => Promise<string>;
getBlindAuthToken?: () => Promise<string>;
},
) => {
const { unit, ...rest } = options;
const { unit, getClearAuthToken, getBlindAuthToken, ...rest } = options;
// Cashu calls the unit 'usd' even though the amount is in cents.
// To avoid this confusion we use 'cent' everywhere and then here we switch the value to 'usd' before creating the Cashu wallet.
const cashuUnit = unit === 'cent' ? 'usd' : unit;
return new ExtendedCashuWallet(new CashuMint(mintUrl), {
...rest,
unit: cashuUnit,
bip39seed: options.bip39seed,
});
return new ExtendedCashuWallet(
new CashuMint(mintUrl, undefined, getBlindAuthToken, getClearAuthToken),
{
...rest,
unit: cashuUnit,
bip39seed: options.bip39seed,
},
);
};

export const getCashuWallet = (
Expand Down
Loading