Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to generate access tokens #86

Merged
merged 33 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
83deb97
Add ability to generate access tokens
imor Aug 24, 2023
dad9f76
factor out smaller components
imor Aug 25, 2023
70e2540
rename a few variables
imor Aug 25, 2023
0305b8a
fix access tokens visible by everyone
imor Aug 25, 2023
f54159e
disable form while creating token
imor Aug 25, 2023
3f1fd0c
fix revoke token not working
imor Aug 25, 2023
6d59191
fix eof
imor Aug 25, 2023
0cbb120
invalidate only access tokens query
imor Aug 28, 2023
c917fc2
simplify nested ternaries
imor Aug 28, 2023
b59e54d
enter submits form
imor Aug 28, 2023
0e8947e
remove more nested ternaries
imor Aug 28, 2023
065cc17
remove unused import
imor Aug 28, 2023
1a0d854
fix loading text color in dark mode
imor Aug 28, 2023
032832d
fix copy button invisible in dark mode
imor Aug 28, 2023
532805a
use auth.uid() instead of user_id variable
imor Aug 28, 2023
d9fa1ad
remove code to put dashes back
imor Aug 28, 2023
943a2b6
add iss claim
imor Aug 28, 2023
4f023bb
use sha256 instead of argon
imor Aug 28, 2023
33ddc54
base64url encode/decode the token
imor Aug 28, 2023
db9fed3
improve new token UI layout
imor Aug 28, 2023
e902ef1
remove hardcoded jwt secret
imor Aug 28, 2023
8dc598d
use on delete cascade
imor Aug 28, 2023
6217e04
prepare to show token prefix/suffix in plaintext
imor Aug 28, 2023
acca2fa
fix new token layout
imor Aug 28, 2023
f50999b
add support for masked token
imor Aug 28, 2023
7b5e3e9
show masked tokens on ui
imor Aug 28, 2023
5e3dc49
fix eof
imor Aug 28, 2023
f26d9de
add comments
imor Aug 28, 2023
376f022
run prettier
alaister Aug 29, 2023
fab89aa
adjust react-queries and add zero access tokens state
alaister Aug 29, 2023
63b87af
remove unnecessary refreshSession call
imor Aug 29, 2023
8ff9c29
Merge branch 'master' into feat/generate-access-tokens
imor Aug 29, 2023
fc1a78f
run prettier
imor Aug 29, 2023
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
196 changes: 196 additions & 0 deletions supabase/migrations/20230817110845_access_token.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
create table app.access_tokens(
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
token_hash bytea not null,
token_name text not null check (length(token_name) <= 64),
plaintext_suffix text not null check (length(plaintext_suffix) = 4),
created_at timestamptz not null default now()
);

grant insert
(id, user_id, token_hash, token_name, plaintext_suffix)
on app.access_tokens
to authenticated;

grant delete
on app.access_tokens
to authenticated;

alter table app.access_tokens enable row level security;

create policy access_tokens_select_policy
on app.access_tokens
as permissive
for select
to public
using ( auth.uid() = user_id );

create policy access_tokens_insert_policy
on app.access_tokens
as permissive
for insert
to authenticated
with check ( auth.uid() = user_id );

create policy access_tokens_delete_policy
on app.access_tokens
as permissive
for delete
to authenticated
using ( auth.uid() = user_id );
Comment on lines +21 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be easier for maintenance to have one permissive policy for all that enforces the auth.uid() = user_id binding, and then add restrictive policies if something needs stopping (like deletes or the such).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look carefully, the topmost policy is for the public role and the other two are for authenticated. So, adding a for all policy and then restricting individual operations will need more policies: two for all policies, three policies for restricting insert, update and, delete to public and two policies for restricting select and update to authenticated. Which makes seven policies in total.


create or replace function app.base64url_encode(input bytea)
returns text
language plpgsql
strict
as $$
begin
return replace(replace(encode(input, 'base64'), '/', '_'), '+', '-');
end;
$$;

create or replace function app.base64url_decode(input text)
returns text
language plpgsql
strict
as $$
begin
return decode(replace(replace(input, '-', '+'), '_', '/'), 'base64');
end;
$$;

create or replace function public.new_access_token(
token_name text
)
returns text
language plpgsql
strict
as $$
<<fn>>
declare
account app.accounts = account from app.accounts account where id = auth.uid();
-- Why 21 random bytes? We are shooting for 128 bit (16 bytes) entropy. But we
-- also show three bytes as plaintext. That takes us to a total 19 bytes.
-- We add two bytes to make sure that the base64 encoded bytes don't have any
-- padding, which makes it a little bit nicer to look at. That makes 21.
token bytea = gen_random_bytes(21);
token_hash bytea = sha256(token);
-- Total length of the base64 encoded token is 21 * 8 / 6 = 28
token_text text = app.base64url_encode(token);
token_id uuid;
-- Last 4 base64 encoded character are shown in the suffix
plaintext_suffix text = substring(token_text from 25);
begin
insert into app.access_tokens(user_id, token_hash, token_name, plaintext_suffix)
values (account.id, token_hash, token_name, fn.plaintext_suffix) returning id into token_id;

-- String returned has a length 64
return 'dbd_' || replace(token_id::text, '-', '') || token_text;
end;
$$;

create type app.access_token_struct as (
id uuid,
token_name text,
masked_token text,
created_at timestamptz
);

create or replace function public.get_access_tokens()
returns setof app.access_token_struct
language plpgsql
strict
as $$
declare
michelp marked this conversation as resolved.
Show resolved Hide resolved
account app.accounts = account from app.accounts account where id = auth.uid();
begin
return query
select id, token_name,
'dbd_' ||
substring(at.id::text from 1 for 4) ||
repeat('•', 52) ||
at.plaintext_suffix as masked_token,
created_at
from app.access_tokens at
where at.user_id = account.id;
end;
$$;

create or replace function public.delete_access_token(
token_id uuid
)
returns void
language plpgsql
strict
as $$
declare
account app.accounts = account from app.accounts account where id = auth.uid();
begin
delete from app.access_tokens at
where at.user_id = account.id and at.id = token_id;
end;
$$;

create type app.user_id_and_token_hash as (
user_id uuid,
token_hash bytea
);

create or replace function public.redeem_access_token(
access_token text
)
returns text
language plpgsql
security definer
strict
as $$
declare
token_id uuid;
token bytea;
tokens_row app.user_id_and_token_hash;
token_valid boolean;
now timestamp;
one_hour_from_now timestamp;
issued_at int;
expiry_at int;
jwt_secret text;
begin
-- validate access token
if length(access_token) != 64 then
raise exception 'Invalid token';
end if;

if substring(access_token from 1 for 4) != 'dbd_' then
raise exception 'Invalid token';
end if;

token_id := substring(access_token from 5 for 32)::uuid;
token := app.base64url_decode(substring(access_token from 37));

select t.user_id, t.token_hash
into tokens_row
from app.access_tokens t
where t.id = token_id;

-- TODO: do a constant time comparison
if tokens_row.token_hash != sha256(token) then
raise exception 'Invalid token';
end if;

-- Generate JWT token
now := current_timestamp;
one_hour_from_now := now + interval '1 hour';
issued_at := date_part('epoch', now);
expiry_at := date_part('epoch', one_hour_from_now);
jwt_secret := current_setting('app.settings.jwt_secret', true);

return sign(json_build_object(
'aud', 'authenticated',
'role', 'authenticated',
'iss', 'database.dev',
'sub', tokens_row.user_id,
'iat', issued_at,
'exp', expiry_at
), jwt_secret);
end;
$$;
46 changes: 46 additions & 0 deletions website/components/access-tokens/AccessTokenCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import dayjs from 'dayjs'
import { toast } from 'react-hot-toast'
import { useDeleteAccessTokenMutation } from '~/data/access-tokens/delete-access-token'
import Button from '../ui/Button'

export interface ApiTokenCardProps {
tokenId: string
tokenName: string
maskedToken: string
createdAt: string
}

const AccessTokenCard = ({
tokenId,
tokenName,
maskedToken,
createdAt,
}: ApiTokenCardProps) => {
const { mutate: deleteAccessToken, isLoading: isDeletingAccessToken } =
useDeleteAccessTokenMutation({
onSuccess() {
toast.success('Successfully revoked token!')
},
})

return (
<div className="rounded-lg px-6 py-5 border border-gray-200 flex justify-between">
<div className="flex flex-col space-y-4">
<div className="font-medium text-lg dark:text-white">{tokenName}</div>
<div className="text-gray-500">{`Token: ${maskedToken}`}</div>
<div className="text-gray-400 text-sm">{`Created ${dayjs(
createdAt
).fromNow()}`}</div>
</div>
<Button
variant="subtle"
onClick={() => deleteAccessToken({ tokenId })}
disabled={isDeletingAccessToken}
>
Revoke
</Button>
</div>
)
}

export default AccessTokenCard
8 changes: 6 additions & 2 deletions website/components/forms/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComponentPropsWithoutRef, forwardRef, PropsWithoutRef } from 'react'
import { useField, UseFieldConfig } from 'react-final-form'
import Input from '../ui/Input'
import Label from '../ui/Label'
import { cn } from '~/lib/utils'

export interface FormInputProps
extends PropsWithoutRef<JSX.IntrinsicElements['input']> {
Expand All @@ -17,7 +18,10 @@ export interface FormInputProps
}

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
({ name, label, outerProps, fieldProps, labelProps, ...props }, ref) => {
(
{ name, label, className, outerProps, fieldProps, labelProps, ...props },
ref
) => {
const {
input,
meta: { touched, error, submitError, submitting },
Expand All @@ -35,7 +39,7 @@ const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
: error || submitError

return (
<div {...outerProps} className="space-y-1">
<div {...outerProps} className={cn('space-y-1', className)}>
<Label htmlFor={name} {...labelProps}>
{label}
</Label>
Expand Down
9 changes: 8 additions & 1 deletion website/components/layouts/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,15 @@ const Navbar = () => {
{displayName}
</Link>
</DropdownMenuItem>

<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/${user?.user_metadata.handle}/_/access-tokens`}
className="flex items-center cursor-pointer"
>
Access Tokens
</Link>
</DropdownMenuItem>

{isOrganizationsSuccess && organizations.length > 0 && (
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
Expand Down
8 changes: 6 additions & 2 deletions website/components/ui/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ const CopyButton = ({

return (
<button
className={cn(copyButtonVariants({ variant }), className)}
className={cn(
copyButtonVariants({ variant }),
'bg-gray-50 dark:bg-gray-400 dark:hover:bg-gray-200',
className
)}
onClick={() => {
copyToClipboardWithMeta(getValue())
setHasCopied(true)
Expand All @@ -66,7 +70,7 @@ const CopyButton = ({
{hasCopied ? (
<CheckIcon className="w-5 h-5" />
) : (
<ClipboardDocumentIcon className="w-5 h-5 dark:text-white" />
<ClipboardDocumentIcon className="w-5 h-5" />
)}
</button>
)
Expand Down
68 changes: 68 additions & 0 deletions website/data/access-tokens/access-tokens-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { PostgrestError } from '@supabase/supabase-js'
import {
QueryClient,
useQuery,
useQueryClient,
UseQueryOptions,
} from '@tanstack/react-query'
import { useCallback } from 'react'
import supabase from '~/lib/supabase'

export type AccessToken = {
id: string
token_name: string
masked_token: string
created_at: string
}

const SELECTED_COLUMNS = [
'id',
'token_name',
'masked_token',
'created_at',
] as const

export type AccessTokensResponse = Pick<
AccessToken,
(typeof SELECTED_COLUMNS)[number]
>[]

export async function getAccessTokens() {
const { data, error } = await supabase.rpc('get_access_tokens', {})

if (error) {
throw error
}

return (data as AccessTokensResponse) ?? []
}

export const accessTokensQueryKey = 'access-tokens'

export type AccessTokensData = Awaited<ReturnType<typeof getAccessTokens>>
export type AccessTokensError = PostgrestError

export const useAccessTokensQuery = <TData = AccessTokensData>({
enabled = true,
...options
}: UseQueryOptions<AccessTokensData, AccessTokensError, TData> = {}) =>
useQuery<AccessTokensData, AccessTokensError, TData>(
[accessTokensQueryKey],
({}) => getAccessTokens(),
{
enabled: enabled,
...options,
}
)

export const prefetchAccessTokens = (client: QueryClient) => {
return client.prefetchQuery([accessTokensQueryKey], ({}) => getAccessTokens())
}

export const useAccessTokensPrefetch = () => {
const client = useQueryClient()

return useCallback(() => {
prefetchAccessTokens(client)
}, [client])
}
Loading
Loading