first commit.
This commit is contained in:
commit
b6bb6585d8
|
|
@ -0,0 +1,491 @@
|
||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
FROM golang:1.22.3-alpine3.20 as authbuild
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
ENV GOOS=linux
|
||||||
|
|
||||||
|
RUN apk add --no-cache make git
|
||||||
|
|
||||||
|
WORKDIR /go/src/github.com/supabase/auth
|
||||||
|
|
||||||
|
# Pulling dependencies
|
||||||
|
COPY auth_v2.169.0/Makefile auth_v2.169.0/go.* ./
|
||||||
|
RUN make deps
|
||||||
|
|
||||||
|
# Building stuff
|
||||||
|
COPY auth_v2.169.0/. ./
|
||||||
|
|
||||||
|
# Make sure you change the RELEASE_VERSION value before publishing an image.
|
||||||
|
RUN RELEASE_VERSION=1.22.3 make build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Base stage for shared environment setup
|
||||||
|
FROM node:20-alpine3.20 as s3base
|
||||||
|
RUN apk add --no-cache g++ make python3
|
||||||
|
WORKDIR /app
|
||||||
|
COPY storage_v1.19.1/package.json storage_v1.19.1/package-lock.json ./
|
||||||
|
|
||||||
|
# Dependencies stage - install and cache all dependencies
|
||||||
|
FROM s3base as dependencies
|
||||||
|
RUN npm ci
|
||||||
|
# Cache the installed node_modules for later stages
|
||||||
|
RUN cp -R node_modules /node_modules_cache
|
||||||
|
|
||||||
|
# Build stage - use cached node_modules for building the application
|
||||||
|
FROM s3base as s3build
|
||||||
|
COPY --from=dependencies /node_modules_cache ./node_modules
|
||||||
|
COPY storage_v1.19.1/. .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production dependencies stage - use npm cache to install only production dependencies
|
||||||
|
FROM s3base as production-deps
|
||||||
|
COPY --from=dependencies /node_modules_cache ./node_modules
|
||||||
|
RUN npm ci --production
|
||||||
|
|
||||||
|
#EXPOSE 5000
|
||||||
|
#CMD ["node", "dist/start/server.js"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Always use alpine:3 so the latest version is used. This will keep CA certs more up to date.
|
||||||
|
#FROM alpine:3
|
||||||
|
FROM nvcr.io/nvidia/tritonserver:24.04-py3-min as base
|
||||||
|
|
||||||
|
RUN mkdir -p /storage-api
|
||||||
|
|
||||||
|
#RUN adduser -D -u 1000 supabase
|
||||||
|
|
||||||
|
#RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
# 创建用户(Ubuntu方式)
|
||||||
|
RUN useradd -m -u 1000 supabase
|
||||||
|
|
||||||
|
# 安装 ca-certificates(Ubuntu方式)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=authbuild /go/src/github.com/supabase/auth/auth /usr/local/bin/auth
|
||||||
|
COPY --from=authbuild /go/src/github.com/supabase/auth/migrations /usr/local/etc/auth/migrations/
|
||||||
|
RUN ln -s /usr/local/bin/auth /usr/local/bin/gotrue
|
||||||
|
|
||||||
|
ENV GOTRUE_DB_MIGRATIONS_PATH /usr/local/etc/auth/migrations
|
||||||
|
|
||||||
|
#USER supabase
|
||||||
|
#CMD ["auth"]
|
||||||
|
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ENV VERSION=$VERSION
|
||||||
|
COPY storage_v1.19.1/migrations migrations
|
||||||
|
|
||||||
|
# Copy production node_modules from the production dependencies stage
|
||||||
|
COPY --from=production-deps /app/node_modules node_modules
|
||||||
|
# Copy build artifacts from the build stage
|
||||||
|
COPY --from=s3build /app/dist dist
|
||||||
|
|
||||||
|
|
||||||
|
#----------------------------------------------------------------- Postgrest --------------------------------
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY postgrest_v12.2.8/postgrest /usr/bin/postgrest
|
||||||
|
RUN chmod +x /usr/bin/postgrest
|
||||||
|
|
||||||
|
#EXPOSE 3000
|
||||||
|
|
||||||
|
#USER 1000
|
||||||
|
|
||||||
|
# Use the array form to avoid running the command using bash, which does not handle `SIGTERM` properly.
|
||||||
|
# See https://docs.docker.com/compose/faq/#why-do-my-services-take-10-seconds-to-recreate-or-stop
|
||||||
|
#CMD ["postgrest"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#========================================================================== PostgreSQL =============================================================
|
||||||
|
ARG postgresql_major=15
|
||||||
|
ARG postgresql_release=${postgresql_major}.1
|
||||||
|
|
||||||
|
# Bump default build arg to build a package from source
|
||||||
|
# Bump vars.yml to specify runtime package version
|
||||||
|
ARG sfcgal_release=1.3.10
|
||||||
|
ARG postgis_release=3.3.2
|
||||||
|
ARG pgrouting_release=3.4.1
|
||||||
|
ARG pgtap_release=1.2.0
|
||||||
|
ARG pg_cron_release=1.6.2
|
||||||
|
ARG pgaudit_release=1.7.0
|
||||||
|
ARG pgjwt_release=9742dab1b2f297ad3811120db7b21451bca2d3c9
|
||||||
|
ARG pgsql_http_release=1.5.0
|
||||||
|
ARG plpgsql_check_release=2.2.5
|
||||||
|
ARG pg_safeupdate_release=1.4
|
||||||
|
ARG timescaledb_release=2.9.1
|
||||||
|
ARG wal2json_release=2_5
|
||||||
|
ARG pljava_release=1.6.4
|
||||||
|
ARG plv8_release=3.1.5
|
||||||
|
ARG pg_plan_filter_release=5081a7b5cb890876e67d8e7486b6a64c38c9a492
|
||||||
|
ARG pg_net_release=0.7.1
|
||||||
|
ARG rum_release=1.3.13
|
||||||
|
ARG pg_hashids_release=cd0e1b31d52b394a0df64079406a14a4f7387cd6
|
||||||
|
ARG libsodium_release=1.0.18
|
||||||
|
ARG pgsodium_release=3.1.6
|
||||||
|
ARG pg_graphql_release=1.5.11
|
||||||
|
ARG pg_stat_monitor_release=1.1.1
|
||||||
|
ARG pg_jsonschema_release=0.1.4
|
||||||
|
ARG pg_repack_release=1.4.8
|
||||||
|
ARG vault_release=0.2.8
|
||||||
|
ARG groonga_release=12.0.8
|
||||||
|
ARG pgroonga_release=2.4.0
|
||||||
|
ARG wrappers_release=0.3.0
|
||||||
|
ARG hypopg_release=1.3.1
|
||||||
|
ARG pgvector_release=0.4.0
|
||||||
|
ARG pg_tle_release=1.3.2
|
||||||
|
ARG index_advisor_release=0.2.0
|
||||||
|
ARG supautils_release=2.2.0
|
||||||
|
ARG wal_g_release=2.0.1
|
||||||
|
|
||||||
|
#FROM ubuntu:focal as base
|
||||||
|
#FROM nvcr.io/nvidia/tritonserver:24.04-py3-min as base
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt update -y && apt install -y \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
lsb-release \
|
||||||
|
software-properties-common \
|
||||||
|
wget \
|
||||||
|
sudo \
|
||||||
|
&& apt clean
|
||||||
|
|
||||||
|
|
||||||
|
RUN adduser --system --home /var/lib/postgresql --no-create-home --shell /bin/bash --group --gecos "PostgreSQL administrator" postgres
|
||||||
|
RUN adduser --system --no-create-home --shell /bin/bash --group wal-g
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux \
|
||||||
|
--init none \
|
||||||
|
--no-confirm \
|
||||||
|
--extra-conf "substituters = https://cache.nixos.org https://nix-postgres-artifacts.s3.amazonaws.com" \
|
||||||
|
--extra-conf "trusted-public-keys = nix-postgres-artifacts:dGZlQOvKcNEjvT7QEAJbcV6b6uk7VF/hWMjhYleiaLI=% cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
||||||
|
|
||||||
|
ENV PATH="${PATH}:/nix/var/nix/profiles/default/bin"
|
||||||
|
|
||||||
|
COPY postgres_15.8.1.044/. /nixpg
|
||||||
|
|
||||||
|
WORKDIR /nixpg
|
||||||
|
|
||||||
|
RUN nix profile install .#psql_15/bin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/lib/postgresql/bin \
|
||||||
|
/usr/lib/postgresql/share/postgresql \
|
||||||
|
/usr/share/postgresql \
|
||||||
|
/var/lib/postgresql \
|
||||||
|
&& chown -R postgres:postgres /usr/lib/postgresql \
|
||||||
|
&& chown -R postgres:postgres /var/lib/postgresql \
|
||||||
|
&& chown -R postgres:postgres /usr/share/postgresql
|
||||||
|
|
||||||
|
# Create symbolic links
|
||||||
|
RUN ln -sf /nix/var/nix/profiles/default/bin/* /usr/lib/postgresql/bin/ \
|
||||||
|
&& ln -sf /nix/var/nix/profiles/default/bin/* /usr/bin/ \
|
||||||
|
&& chown -R postgres:postgres /usr/bin
|
||||||
|
|
||||||
|
# Create symbolic links for PostgreSQL shares
|
||||||
|
RUN ln -sf /nix/var/nix/profiles/default/share/postgresql/* /usr/lib/postgresql/share/postgresql/
|
||||||
|
RUN ln -sf /nix/var/nix/profiles/default/share/postgresql/* /usr/share/postgresql/
|
||||||
|
RUN chown -R postgres:postgres /usr/lib/postgresql/share/postgresql/
|
||||||
|
RUN chown -R postgres:postgres /usr/share/postgresql/
|
||||||
|
# Create symbolic links for contrib directory
|
||||||
|
RUN mkdir -p /usr/lib/postgresql/share/postgresql/contrib \
|
||||||
|
&& find /nix/var/nix/profiles/default/share/postgresql/contrib/ -mindepth 1 -type d -exec sh -c 'for dir do ln -s "$dir" "/usr/lib/postgresql/share/postgresql/contrib/$(basename "$dir")"; done' sh {} + \
|
||||||
|
&& chown -R postgres:postgres /usr/lib/postgresql/share/postgresql/contrib/
|
||||||
|
|
||||||
|
RUN chown -R postgres:postgres /usr/lib/postgresql
|
||||||
|
|
||||||
|
RUN ln -sf /usr/lib/postgresql/share/postgresql/timezonesets /usr/share/postgresql/timezonesets
|
||||||
|
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends tzdata
|
||||||
|
|
||||||
|
RUN ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime && \
|
||||||
|
dpkg-reconfigure --frontend noninteractive tzdata
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
checkinstall \
|
||||||
|
cmake
|
||||||
|
|
||||||
|
ENV PGDATA=/var/lib/postgresql/data
|
||||||
|
|
||||||
|
####################
|
||||||
|
# setup-wal-g.yml
|
||||||
|
####################
|
||||||
|
FROM base as walg
|
||||||
|
ARG wal_g_release
|
||||||
|
# ADD "https://github.com/wal-g/wal-g/releases/download/v${wal_g_release}/wal-g-pg-ubuntu-20.04-${TARGETARCH}.tar.gz" /tmp/wal-g.tar.gz
|
||||||
|
RUN arch=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "$TARGETARCH") && \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends curl && \
|
||||||
|
curl -kL "https://github.com/wal-g/wal-g/releases/download/v${wal_g_release}/wal-g-pg-ubuntu-20.04-aarch64.tar.gz" -o /tmp/wal-g.tar.gz && \
|
||||||
|
tar -xvf /tmp/wal-g.tar.gz -C /tmp && \
|
||||||
|
rm -rf /tmp/wal-g.tar.gz && \
|
||||||
|
mv /tmp/wal-g-pg-ubuntu*20.04-aarch64 /tmp/wal-g
|
||||||
|
|
||||||
|
# ####################
|
||||||
|
# # Download gosu for easy step-down from root
|
||||||
|
# ####################
|
||||||
|
FROM base as gosu
|
||||||
|
ARG TARGETARCH
|
||||||
|
# Install dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gnupg \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# Download binary
|
||||||
|
ARG GOSU_VERSION=1.16
|
||||||
|
ARG GOSU_GPG_KEY=B42F6819007F00F88E364FD4036A9C25BF357DD4
|
||||||
|
ADD https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$TARGETARCH \
|
||||||
|
/usr/local/bin/gosu
|
||||||
|
ADD https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$TARGETARCH.asc \
|
||||||
|
/usr/local/bin/gosu.asc
|
||||||
|
# Verify checksum
|
||||||
|
RUN gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys $GOSU_GPG_KEY && \
|
||||||
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu && \
|
||||||
|
gpgconf --kill all && \
|
||||||
|
chmod +x /usr/local/bin/gosu
|
||||||
|
|
||||||
|
# ####################
|
||||||
|
# # Build final image
|
||||||
|
# ####################
|
||||||
|
FROM gosu as production
|
||||||
|
RUN id postgres || (echo "postgres user does not exist" && exit 1)
|
||||||
|
# # Setup extensions
|
||||||
|
COPY --from=walg /tmp/wal-g /usr/local/bin/
|
||||||
|
|
||||||
|
# # Initialise configs
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/postgresql.conf.j2 /etc/postgresql/postgresql.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/pg_hba.conf.j2 /etc/postgresql/pg_hba.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/pg_ident.conf.j2 /etc/postgresql/pg_ident.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/postgresql-stdout-log.conf /etc/postgresql/logging.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/supautils.conf.j2 /etc/postgresql-custom/supautils.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_extension_custom_scripts /etc/postgresql-custom/extension-custom-scripts
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/pgsodium_getkey_urandom.sh.j2 /usr/lib/postgresql/bin/pgsodium_getkey.sh
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/custom_read_replica.conf.j2 /etc/postgresql-custom/read-replica.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/postgresql_config/custom_walg.conf.j2 /etc/postgresql-custom/wal-g.conf
|
||||||
|
COPY --chown=postgres:postgres postgres_15.8.1.044/ansible/files/walg_helper_scripts/wal_fetch.sh /home/postgres/wal_fetch.sh
|
||||||
|
COPY postgres_15.8.1.044/ansible/files/walg_helper_scripts/wal_change_ownership.sh /root/wal_change_ownership.sh
|
||||||
|
|
||||||
|
RUN sed -i \
|
||||||
|
-e "s|#unix_socket_directories = '/tmp'|unix_socket_directories = '/var/run/postgresql'|g" \
|
||||||
|
-e "s|#session_preload_libraries = ''|session_preload_libraries = 'supautils'|g" \
|
||||||
|
-e "s|#include = '/etc/postgresql-custom/supautils.conf'|include = '/etc/postgresql-custom/supautils.conf'|g" \
|
||||||
|
-e "s|#include = '/etc/postgresql-custom/wal-g.conf'|include = '/etc/postgresql-custom/wal-g.conf'|g" /etc/postgresql/postgresql.conf && \
|
||||||
|
echo "cron.database_name = 'postgres'" >> /etc/postgresql/postgresql.conf && \
|
||||||
|
#echo "pljava.libjvm_location = '/usr/lib/jvm/java-11-openjdk-${TARGETARCH}/lib/server/libjvm.so'" >> /etc/postgresql/postgresql.conf && \
|
||||||
|
echo "pgsodium.getkey_script= '/usr/lib/postgresql/bin/pgsodium_getkey.sh'" >> /etc/postgresql/postgresql.conf && \
|
||||||
|
echo 'auto_explain.log_min_duration = 10s' >> /etc/postgresql/postgresql.conf && \
|
||||||
|
usermod -aG postgres wal-g && \
|
||||||
|
mkdir -p /etc/postgresql-custom && \
|
||||||
|
chown postgres:postgres /etc/postgresql-custom
|
||||||
|
|
||||||
|
# # Include schema migrations
|
||||||
|
COPY postgres_15.8.1.044/migrations/db /docker-entrypoint-initdb.d/
|
||||||
|
COPY postgres_15.8.1.044/ansible/files/pgbouncer_config/pgbouncer_auth_schema.sql /docker-entrypoint-initdb.d/init-scripts/00-schema.sql
|
||||||
|
COPY postgres_15.8.1.044/ansible/files/stat_extension.sql /docker-entrypoint-initdb.d/migrations/00-extension.sql
|
||||||
|
|
||||||
|
# # Add upstream entrypoint script
|
||||||
|
COPY --from=gosu /usr/local/bin/gosu /usr/local/bin/gosu
|
||||||
|
ADD --chmod=0755 \
|
||||||
|
https://github.com/docker-library/postgres/raw/master/15/bullseye/docker-entrypoint.sh \
|
||||||
|
/usr/local/bin/
|
||||||
|
|
||||||
|
RUN mkdir -p /var/run/postgresql && chown postgres:postgres /var/run/postgresql
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=2s --timeout=2s --retries=10 CMD pg_isready -U postgres -h localhost
|
||||||
|
STOPSIGNAL SIGINT
|
||||||
|
EXPOSE 5432
|
||||||
|
|
||||||
|
ENV POSTGRES_HOST=/var/run/postgresql
|
||||||
|
ENV POSTGRES_USER=supabase_admin
|
||||||
|
ENV POSTGRES_DB=postgres
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
locales \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* && \
|
||||||
|
localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||||
|
&& localedef -i C -c -f UTF-8 -A /usr/share/locale/locale.alias C.UTF-8
|
||||||
|
RUN echo "C.UTF-8 UTF-8" > /etc/locale.gen && echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen
|
||||||
|
ENV LANG en_US.UTF-8
|
||||||
|
ENV LANGUAGE en_US:en
|
||||||
|
ENV LC_ALL en_US.UTF-8
|
||||||
|
ENV LC_CTYPE=C.UTF-8
|
||||||
|
ENV LC_COLLATE=C.UTF-8
|
||||||
|
ENV LOCALE_ARCHIVE /usr/lib/locale/locale-archive
|
||||||
|
#CMD ["postgres", "-D", "/etc/postgresql"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#=================================================== kong =====================================================================
|
||||||
|
|
||||||
|
ARG ASSET=ce
|
||||||
|
ENV ASSET $ASSET
|
||||||
|
|
||||||
|
ARG EE_PORTS
|
||||||
|
|
||||||
|
COPY docker-kong_v2.8.1/ubuntu/kong.deb /tmp/kong.deb
|
||||||
|
|
||||||
|
ARG KONG_VERSION=2.8.1
|
||||||
|
ENV KONG_VERSION $KONG_VERSION
|
||||||
|
|
||||||
|
ARG KONG_AMD64_SHA="10d12d23e5890414d666663094d51a42de41f8a9806fbc0baaf9ac4d37794361"
|
||||||
|
ARG KONG_ARM64_SHA="61c13219ef64dac9aeae5ae775411e8cfcd406f068cf3e75d463f916ae6513cb"
|
||||||
|
|
||||||
|
# hadolint ignore=DL3015
|
||||||
|
RUN set -ex; \
|
||||||
|
arch=$(dpkg --print-architecture); \
|
||||||
|
case "${arch}" in \
|
||||||
|
amd64) KONG_SHA256=$KONG_AMD64_SHA ;; \
|
||||||
|
arm64) KONG_SHA256=$KONG_ARM64_SHA ;; \
|
||||||
|
esac; \
|
||||||
|
apt-get update \
|
||||||
|
&& if [ "$ASSET" = "ce" ] ; then \
|
||||||
|
apt-get install -y curl \
|
||||||
|
&& UBUNTU_CODENAME=focal \
|
||||||
|
&& KONG_REPO=$(echo ${KONG_VERSION%.*} | sed 's/\.//') \
|
||||||
|
&& curl -fL https://packages.konghq.com/public/gateway-$KONG_REPO/deb/ubuntu/pool/$UBUNTU_CODENAME/main/k/ko/kong_$KONG_VERSION/kong_${KONG_VERSION}_$arch.deb -o /tmp/kong.deb \
|
||||||
|
&& apt-get purge -y curl \
|
||||||
|
&& echo "$KONG_SHA256 /tmp/kong.deb" | sha256sum -c -; \
|
||||||
|
else \
|
||||||
|
# this needs to stay inside this "else" block so that it does not become part of the "official images" builds (https://github.com/docker-library/official-images/pull/11532#issuecomment-996219700)
|
||||||
|
apt-get upgrade -y ; \
|
||||||
|
fi; \
|
||||||
|
apt-get install -y --no-install-recommends unzip git \
|
||||||
|
# Please update the ubuntu install docs if the below line is changed so that
|
||||||
|
# end users can properly install Kong along with its required dependencies
|
||||||
|
# and that our CI does not diverge from our docs.
|
||||||
|
&& apt install --yes /tmp/kong.deb \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& rm -rf /tmp/kong.deb \
|
||||||
|
&& chown kong:0 /usr/local/bin/kong \
|
||||||
|
&& chown -R kong:0 /usr/local/kong \
|
||||||
|
&& ln -s /usr/local/openresty/bin/resty /usr/local/bin/resty \
|
||||||
|
&& ln -s /usr/local/openresty/luajit/bin/luajit /usr/local/bin/luajit \
|
||||||
|
&& ln -s /usr/local/openresty/luajit/bin/luajit /usr/local/bin/lua \
|
||||||
|
&& ln -s /usr/local/openresty/nginx/sbin/nginx /usr/local/bin/nginx \
|
||||||
|
&& if [ "$ASSET" = "ce" ] ; then \
|
||||||
|
kong version ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
COPY docker-kong_v2.8.1/ubuntu/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
|
#USER kong
|
||||||
|
|
||||||
|
#ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
#EXPOSE 8000 8443 8001 8444 $EE_PORTS
|
||||||
|
|
||||||
|
#STOPSIGNAL SIGQUIT
|
||||||
|
|
||||||
|
#HEALTHCHECK --interval=10s --timeout=10s --retries=10 CMD kong health
|
||||||
|
|
||||||
|
#CMD ["kong", "docker-start"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ARG CUDA_VERSION=12.5.1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#============================================= sglang ============================================
|
||||||
|
#FROM nvcr.io/nvidia/tritonserver:24.04-py3-min
|
||||||
|
|
||||||
|
ARG BUILD_TYPE=all
|
||||||
|
#ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
|
||||||
|
# 安装依赖(强制 IPv4)
|
||||||
|
RUN echo 'tzdata tzdata/Areas select Asia' | debconf-set-selections \
|
||||||
|
&& echo 'tzdata tzdata/Zones/Asia select Shanghai' | debconf-set-selections \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true update -y \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true install software-properties-common -y \
|
||||||
|
&& add-apt-repository ppa:deadsnakes/ppa -y \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true update \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true install python3.10 python3.10-dev -y \
|
||||||
|
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.10 1 \
|
||||||
|
&& update-alternatives --set python3 /usr/bin/python3.10 \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true install python3.10-distutils -y \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true install curl gnupg gnupg wget git sudo libibverbs-dev -y \
|
||||||
|
&& apt -o Acquire::ForceIPv4=true install -y rdma-core infiniband-diags openssh-server perftest ibverbs-providers libibumad3 libibverbs1 libnl-3-200 libnl-route-3-200 librdmacm1 \
|
||||||
|
&& curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py \
|
||||||
|
&& python3 get-pip.py \
|
||||||
|
&& python3 --version \
|
||||||
|
&& python3 -m pip --version \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& apt clean
|
||||||
|
|
||||||
|
|
||||||
|
# 安装 datamodel_code_generator(用于 MiniCPM 模型)
|
||||||
|
RUN pip3 install datamodel_code_generator
|
||||||
|
|
||||||
|
WORKDIR /sgl-workspace
|
||||||
|
|
||||||
|
# 拷贝 sglang 源代码并构建包
|
||||||
|
COPY ./sglang /sgl-workspace/sglang
|
||||||
|
|
||||||
|
# 拷贝模型文件(修正方式)
|
||||||
|
#COPY ./Alibaba/QwQ-32B /root/.cradle/Alibaba/QwQ-32B
|
||||||
|
|
||||||
|
ARG CUDA_VERSION
|
||||||
|
|
||||||
|
# 安装依赖、安装 sglang、安装 transformers,并清理源码
|
||||||
|
RUN python3 -m pip install --upgrade pip setuptools wheel html5lib six \
|
||||||
|
&& if [ "$CUDA_VERSION" = "12.1.1" ]; then \
|
||||||
|
CUINDEX=121; \
|
||||||
|
elif [ "$CUDA_VERSION" = "12.4.1" ]; then \
|
||||||
|
CUINDEX=124; \
|
||||||
|
elif [ "$CUDA_VERSION" = "12.5.1" ]; then \
|
||||||
|
CUINDEX=124; \
|
||||||
|
elif [ "$CUDA_VERSION" = "11.8.0" ]; then \
|
||||||
|
CUINDEX=118; \
|
||||||
|
python3 -m pip install --no-cache-dir sgl-kernel -i https://docs.sglang.ai/whl/cu118; \
|
||||||
|
else \
|
||||||
|
echo "Unsupported CUDA version: $CUDA_VERSION" && exit 1; \
|
||||||
|
fi \
|
||||||
|
&& python3 -m pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cu${CUINDEX} \
|
||||||
|
&& python3 -m pip install --no-cache-dir psutil pyzmq pynvml \
|
||||||
|
&& cd /sgl-workspace/sglang/python \
|
||||||
|
&& python3 -m pip install --no-cache-dir '.[srt,openai]' --find-links https://flashinfer.ai/whl/cu${CUINDEX}/torch2.5/flashinfer-python \
|
||||||
|
&& cd / && rm -rf /sgl-workspace/sglang \
|
||||||
|
&& python3 -m pip install --no-cache-dir transformers==4.48.3 \
|
||||||
|
&& python3 -c "import sglang; print('✅ sglang module installed successfully')"
|
||||||
|
|
||||||
|
|
||||||
|
#================================================== PostgreSQL ==============================================================
|
||||||
|
RUN adduser --system --home /var/lib/postgresql --no-create-home --shell /bin/bash --group --gecos "PostgreSQL administrator" postgres
|
||||||
|
RUN adduser --system --no-create-home --shell /bin/bash --group wal-g
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux \
|
||||||
|
--init none \
|
||||||
|
--no-confirm \
|
||||||
|
--extra-conf "substituters = https://cache.nixos.org https://nix-postgres-artifacts.s3.amazonaws.com" \
|
||||||
|
--extra-conf "trusted-public-keys = nix-postgres-artifacts:dGZlQOvKcNEjvT7QEAJbcV6b6uk7VF/hWMjhYleiaLI=% cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
||||||
|
|
||||||
|
ENV PATH="${PATH}:/nix/var/nix/profiles/default/bin"
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
/hack/
|
||||||
|
/vendor/
|
||||||
|
/www/
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Set the default behavior
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Go files
|
||||||
|
*.mod text eol=lf
|
||||||
|
*.sum text eol=lf
|
||||||
|
*.go text eol=lf
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
*.yml eol=lf
|
||||||
|
*.yaml eol=lf
|
||||||
|
*.toml eol=lf
|
||||||
|
*.json eol=lf
|
||||||
|
|
||||||
|
# Scripts
|
||||||
|
*.sh eol=lf
|
||||||
|
|
||||||
|
# DB files
|
||||||
|
*.sql eol=lf
|
||||||
|
|
||||||
|
# Html
|
||||||
|
*.html eol=lf
|
||||||
|
|
||||||
|
# Text and markdown files
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
|
||||||
|
# Environment files/examples
|
||||||
|
*.env text eol=lf
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
.dockerignore text eol=lf
|
||||||
|
Dockerfile* text eol=lf
|
||||||
|
|
||||||
|
# Makefile
|
||||||
|
Makefile text eol=lf
|
||||||
|
|
||||||
|
# Git files
|
||||||
|
.gitignore text eol=lf
|
||||||
|
.gitattributes text eol=lf
|
||||||
|
.gitkeep text eol=lf
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
.env*
|
||||||
|
vendor/
|
||||||
|
gotrue
|
||||||
|
gotrue-arm64
|
||||||
|
gotrue.exe
|
||||||
|
auth
|
||||||
|
auth-arm64
|
||||||
|
auth.exe
|
||||||
|
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
www/dist/
|
||||||
|
www/.DS_Store
|
||||||
|
www/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.data
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"branches": [
|
||||||
|
"master"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/github"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,601 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [2.169.0](https://github.com/supabase/auth/compare/v2.168.0...v2.169.0) (2025-01-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add an optional burstable rate limiter ([#1924](https://github.com/supabase/auth/issues/1924)) ([1f06f58](https://github.com/supabase/auth/commit/1f06f58e1434b91612c0d96c8c0435d26570f3e2))
|
||||||
|
* cover 100% of crypto with tests ([#1892](https://github.com/supabase/auth/issues/1892)) ([174198e](https://github.com/supabase/auth/commit/174198e56f8e9b8470a717d0021c626130288d2e))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* convert refreshed_at to UTC before updating ([#1916](https://github.com/supabase/auth/issues/1916)) ([a4c692f](https://github.com/supabase/auth/commit/a4c692f6cb1b8bf4c47ea012872af5ce93382fbf))
|
||||||
|
* correct casing of API key authentication in openapi.yaml ([0cfd177](https://github.com/supabase/auth/commit/0cfd177b8fb1df8f62e84fbd3761ef9f90c384de))
|
||||||
|
* improve invalid channel error message returned ([#1908](https://github.com/supabase/auth/issues/1908)) ([f72f0ee](https://github.com/supabase/auth/commit/f72f0eee328fa0aa041155f5f5dc305f0874d2bf))
|
||||||
|
* improve saml assertion logging ([#1915](https://github.com/supabase/auth/issues/1915)) ([d6030cc](https://github.com/supabase/auth/commit/d6030ccd271a381e2a6ababa11a5beae4b79e5c3))
|
||||||
|
|
||||||
|
## [2.168.0](https://github.com/supabase/auth/compare/v2.167.0...v2.168.0) (2025-01-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* set `email_verified` to true on all identities with the verified email ([#1902](https://github.com/supabase/auth/issues/1902)) ([307892f](https://github.com/supabase/auth/commit/307892f85b39150074fbb80b9c8f45ac3312aae2))
|
||||||
|
|
||||||
|
## [2.167.0](https://github.com/supabase/auth/compare/v2.166.0...v2.167.0) (2024-12-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* fix argon2 parsing and comparison ([#1887](https://github.com/supabase/auth/issues/1887)) ([9dbe6ef](https://github.com/supabase/auth/commit/9dbe6ef931ae94e621d55a5f7aea4b7ee0449949))
|
||||||
|
|
||||||
|
## [2.166.0](https://github.com/supabase/auth/compare/v2.165.0...v2.166.0) (2024-12-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* switch to googleapis/release-please-action, bump to 2.166.0 ([#1883](https://github.com/supabase/auth/issues/1883)) ([11a312f](https://github.com/supabase/auth/commit/11a312fcf77771b3732f2f439078225895df7a85))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* check if session is nil ([#1873](https://github.com/supabase/auth/issues/1873)) ([fd82601](https://github.com/supabase/auth/commit/fd82601917adcd9f8c38263953eb1ef098b26b7f))
|
||||||
|
* email_verified field not being updated on signup confirmation ([#1868](https://github.com/supabase/auth/issues/1868)) ([483463e](https://github.com/supabase/auth/commit/483463e49eec7b2974cca05eadca6b933b2145b5))
|
||||||
|
* handle user banned error code ([#1851](https://github.com/supabase/auth/issues/1851)) ([a6918f4](https://github.com/supabase/auth/commit/a6918f49baee42899b3ae1b7b6bc126d84629c99))
|
||||||
|
* Revert "fix: revert fallback on btree indexes when hash is unavailable" ([#1859](https://github.com/supabase/auth/issues/1859)) ([9fe5b1e](https://github.com/supabase/auth/commit/9fe5b1eebfafb385d6b5d10196aeb2a1964ab296))
|
||||||
|
* skip cleanup for non-2xx status ([#1877](https://github.com/supabase/auth/issues/1877)) ([f572ced](https://github.com/supabase/auth/commit/f572ced3699c7f920deccce1a3539299541ec94c))
|
||||||
|
|
||||||
|
## [2.165.1](https://github.com/supabase/auth/compare/v2.165.0...v2.165.1) (2024-12-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow setting the mailer service headers as strings ([#1861](https://github.com/supabase/auth/issues/1861)) ([7907b56](https://github.com/supabase/auth/commit/7907b566228f7e2d76049b44cfe0cc808c109100))
|
||||||
|
|
||||||
|
## [2.165.0](https://github.com/supabase/auth/compare/v2.164.0...v2.165.0) (2024-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add email validation function to lower bounce rates ([#1845](https://github.com/supabase/auth/issues/1845)) ([2c291f0](https://github.com/supabase/auth/commit/2c291f0356f3e91063b6b43bf2a21625b0ce0ebd))
|
||||||
|
* use embedded migrations for `migrate` command ([#1843](https://github.com/supabase/auth/issues/1843)) ([e358da5](https://github.com/supabase/auth/commit/e358da5f0e267725a77308461d0a4126436fc537))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* fallback on btree indexes when hash is unavailable ([#1856](https://github.com/supabase/auth/issues/1856)) ([b33bc31](https://github.com/supabase/auth/commit/b33bc31c07549dc9dc221100995d6f6b6754fd3a))
|
||||||
|
* return the error code instead of status code ([#1855](https://github.com/supabase/auth/issues/1855)) ([834a380](https://github.com/supabase/auth/commit/834a380d803ae9ce59ce5ee233fa3a78a984fe68))
|
||||||
|
* revert fallback on btree indexes when hash is unavailable ([#1858](https://github.com/supabase/auth/issues/1858)) ([1c7202f](https://github.com/supabase/auth/commit/1c7202ff835856562ee66b33be131eca769acf1d))
|
||||||
|
* update ip mismatch error message ([#1849](https://github.com/supabase/auth/issues/1849)) ([49fbbf0](https://github.com/supabase/auth/commit/49fbbf03917a1085c58e9a1ff76c247ae6bb9ca7))
|
||||||
|
|
||||||
|
## [2.164.0](https://github.com/supabase/auth/compare/v2.163.2...v2.164.0) (2024-11-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* return validation failed error if captcha request was not json ([#1815](https://github.com/supabase/auth/issues/1815)) ([26d2e36](https://github.com/supabase/auth/commit/26d2e36bba29eb8a6ddba556acfd0820f3bfde5d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add error codes to refresh token flow ([#1824](https://github.com/supabase/auth/issues/1824)) ([4614dc5](https://github.com/supabase/auth/commit/4614dc54ab1dcb5390cfed05441e7888af017d92))
|
||||||
|
* add test coverage for rate limits with 0 permitted events ([#1834](https://github.com/supabase/auth/issues/1834)) ([7c3cf26](https://github.com/supabase/auth/commit/7c3cf26cfe2a3e4de579d10509945186ad719855))
|
||||||
|
* correct web authn aaguid column naming ([#1826](https://github.com/supabase/auth/issues/1826)) ([0a589d0](https://github.com/supabase/auth/commit/0a589d04e1cd9310cb260d329bc8beb050adf8da))
|
||||||
|
* default to files:read scope for Figma provider ([#1831](https://github.com/supabase/auth/issues/1831)) ([9ce2857](https://github.com/supabase/auth/commit/9ce28570bf3da9571198d44d693c7ad7038cde33))
|
||||||
|
* improve error messaging for http hooks ([#1821](https://github.com/supabase/auth/issues/1821)) ([fa020d0](https://github.com/supabase/auth/commit/fa020d0fc292d5c381c57ecac6666d9ff657e4c4))
|
||||||
|
* make drop_uniqueness_constraint_on_phone idempotent ([#1817](https://github.com/supabase/auth/issues/1817)) ([158e473](https://github.com/supabase/auth/commit/158e4732afa17620cdd89c85b7b57569feea5c21))
|
||||||
|
* possible panic if refresh token has a null session_id ([#1822](https://github.com/supabase/auth/issues/1822)) ([a7129df](https://github.com/supabase/auth/commit/a7129df4e1d91a042b56ff1f041b9c6598825475))
|
||||||
|
* rate limits of 0 take precedence over MAILER_AUTO_CONFIRM ([#1837](https://github.com/supabase/auth/issues/1837)) ([cb7894e](https://github.com/supabase/auth/commit/cb7894e1119d27d527dedcca22d8b3d433beddac))
|
||||||
|
|
||||||
|
## [2.163.2](https://github.com/supabase/auth/compare/v2.163.1...v2.163.2) (2024-10-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* ignore rate limits for autoconfirm ([#1810](https://github.com/supabase/auth/issues/1810)) ([9ce2340](https://github.com/supabase/auth/commit/9ce23409f960a8efa55075931138624cb681eca5))
|
||||||
|
|
||||||
|
## [2.163.1](https://github.com/supabase/auth/compare/v2.163.0...v2.163.1) (2024-10-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* external host validation ([#1808](https://github.com/supabase/auth/issues/1808)) ([4f6a461](https://github.com/supabase/auth/commit/4f6a4617074e61ba3b31836ccb112014904ce97c)), closes [#1228](https://github.com/supabase/auth/issues/1228)
|
||||||
|
|
||||||
|
## [2.163.0](https://github.com/supabase/auth/compare/v2.162.2...v2.163.0) (2024-10-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add mail header support via `GOTRUE_SMTP_HEADERS` with `$messageType` ([#1804](https://github.com/supabase/auth/issues/1804)) ([99d6a13](https://github.com/supabase/auth/commit/99d6a134c44554a8ad06695e1dff54c942c8335d))
|
||||||
|
* add MFA for WebAuthn ([#1775](https://github.com/supabase/auth/issues/1775)) ([8cc2f0e](https://github.com/supabase/auth/commit/8cc2f0e14d06d0feb56b25a0278fda9e213b6b5a))
|
||||||
|
* configurable email and sms rate limiting ([#1800](https://github.com/supabase/auth/issues/1800)) ([5e94047](https://github.com/supabase/auth/commit/5e9404717e1c962ab729cde150ef5b40ea31a6e8))
|
||||||
|
* mailer logging ([#1805](https://github.com/supabase/auth/issues/1805)) ([9354b83](https://github.com/supabase/auth/commit/9354b83a48a3edcb49197c997a1e96efc80c5383))
|
||||||
|
* preserve rate limiters in memory across configuration reloads ([#1792](https://github.com/supabase/auth/issues/1792)) ([0a3968b](https://github.com/supabase/auth/commit/0a3968b02b9f044bfb7e5ebc71dca970d2bb7807))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add twilio verify support on mfa ([#1714](https://github.com/supabase/auth/issues/1714)) ([aeb5d8f](https://github.com/supabase/auth/commit/aeb5d8f8f18af60ce369cab5714979ac0c208308))
|
||||||
|
* email header setting no longer misleading ([#1802](https://github.com/supabase/auth/issues/1802)) ([3af03be](https://github.com/supabase/auth/commit/3af03be6b65c40f3f4f62ce9ab989a20d75ae53a))
|
||||||
|
* enforce authorized address checks on send email only ([#1806](https://github.com/supabase/auth/issues/1806)) ([c0c5b23](https://github.com/supabase/auth/commit/c0c5b23728c8fb633dae23aa4b29ed60e2691a2b))
|
||||||
|
* fix `getExcludedColumns` slice allocation ([#1788](https://github.com/supabase/auth/issues/1788)) ([7f006b6](https://github.com/supabase/auth/commit/7f006b63c8d7e28e55a6d471881e9c118df80585))
|
||||||
|
* Fix reqPath for bypass check for verify EP ([#1789](https://github.com/supabase/auth/issues/1789)) ([646dc66](https://github.com/supabase/auth/commit/646dc66ea8d59a7f78bf5a5e55d9b5065a718c23))
|
||||||
|
* inline mailme package for easy development ([#1803](https://github.com/supabase/auth/issues/1803)) ([fa6f729](https://github.com/supabase/auth/commit/fa6f729a027eff551db104550fa626088e00bc15))
|
||||||
|
|
||||||
|
## [2.162.2](https://github.com/supabase/auth/compare/v2.162.1...v2.162.2) (2024-10-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* refactor mfa validation into functions ([#1780](https://github.com/supabase/auth/issues/1780)) ([410b8ac](https://github.com/supabase/auth/commit/410b8acdd659fc4c929fe57a9e9dba4c76da305d))
|
||||||
|
* upgrade ci Go version ([#1782](https://github.com/supabase/auth/issues/1782)) ([97a48f6](https://github.com/supabase/auth/commit/97a48f6daaa2edda5b568939cbb1007ccdf33cfc))
|
||||||
|
* validateEmail should normalise emails ([#1790](https://github.com/supabase/auth/issues/1790)) ([2e9b144](https://github.com/supabase/auth/commit/2e9b144a0cbf2d26d3c4c2eafbff1899a36aeb3b))
|
||||||
|
|
||||||
|
## [2.162.1](https://github.com/supabase/auth/compare/v2.162.0...v2.162.1) (2024-10-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bypass check for token & verify endpoints ([#1785](https://github.com/supabase/auth/issues/1785)) ([9ac2ea0](https://github.com/supabase/auth/commit/9ac2ea0180826cd2f65e679524aabfb10666e973))
|
||||||
|
|
||||||
|
## [2.162.0](https://github.com/supabase/auth/compare/v2.161.0...v2.162.0) (2024-09-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add support for migration of firebase scrypt passwords ([#1768](https://github.com/supabase/auth/issues/1768)) ([ba00f75](https://github.com/supabase/auth/commit/ba00f75c28d6708ddf8ee151ce18f2d6193689ef))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* apply authorized email restriction to non-admin routes ([#1778](https://github.com/supabase/auth/issues/1778)) ([1af203f](https://github.com/supabase/auth/commit/1af203f92372e6db12454a0d319aad8ce3d149e7))
|
||||||
|
* magiclink failing due to passwordStrength check ([#1769](https://github.com/supabase/auth/issues/1769)) ([7a5411f](https://github.com/supabase/auth/commit/7a5411f1d4247478f91027bc4969cbbe95b7774c))
|
||||||
|
|
||||||
|
## [2.161.0](https://github.com/supabase/auth/compare/v2.160.0...v2.161.0) (2024-09-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add `x-sb-error-code` header, show error code in logs ([#1765](https://github.com/supabase/auth/issues/1765)) ([ed91c59](https://github.com/supabase/auth/commit/ed91c59aa332738bd0ac4b994aeec2cdf193a068))
|
||||||
|
* add webauthn configuration variables ([#1773](https://github.com/supabase/auth/issues/1773)) ([77d5897](https://github.com/supabase/auth/commit/77d58976ae624dbb7f8abee041dd4557aab81109))
|
||||||
|
* config reloading ([#1771](https://github.com/supabase/auth/issues/1771)) ([6ee0091](https://github.com/supabase/auth/commit/6ee009163bfe451e2a0b923705e073928a12c004))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add additional information around errors for missing content type header ([#1576](https://github.com/supabase/auth/issues/1576)) ([c2b2f96](https://github.com/supabase/auth/commit/c2b2f96f07c97c15597cd972b1cd672238d87cdc))
|
||||||
|
* add token to hook payload for non-secure email change ([#1763](https://github.com/supabase/auth/issues/1763)) ([7e472ad](https://github.com/supabase/auth/commit/7e472ad72042e86882dab3fddce9fafa66a8236c))
|
||||||
|
* update aal requirements to update user ([#1766](https://github.com/supabase/auth/issues/1766)) ([25d9874](https://github.com/supabase/auth/commit/25d98743f6cc2cca2b490a087f468c8556ec5e44))
|
||||||
|
* update mfa admin methods ([#1774](https://github.com/supabase/auth/issues/1774)) ([567ea7e](https://github.com/supabase/auth/commit/567ea7ebd18eacc5e6daea8adc72e59e94459991))
|
||||||
|
* user sanitization should clean up email change info too ([#1759](https://github.com/supabase/auth/issues/1759)) ([9d419b4](https://github.com/supabase/auth/commit/9d419b400f0637b10e5c235b8fd5bac0d69352bd))
|
||||||
|
|
||||||
|
## [2.160.0](https://github.com/supabase/auth/compare/v2.159.2...v2.160.0) (2024-09-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add authorized email address support ([#1757](https://github.com/supabase/auth/issues/1757)) ([f3a28d1](https://github.com/supabase/auth/commit/f3a28d182d193cf528cc72a985dfeaf7ecb67056))
|
||||||
|
* add option to disable magic links ([#1756](https://github.com/supabase/auth/issues/1756)) ([2ad0737](https://github.com/supabase/auth/commit/2ad07373aa9239eba94abdabbb01c9abfa8c48de))
|
||||||
|
* add support for saml encrypted assertions ([#1752](https://github.com/supabase/auth/issues/1752)) ([c5480ef](https://github.com/supabase/auth/commit/c5480ef83248ec2e7e3d3d87f92f43f17161ed25))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* apply shared limiters before email / sms is sent ([#1748](https://github.com/supabase/auth/issues/1748)) ([bf276ab](https://github.com/supabase/auth/commit/bf276ab49753642793471815727559172fea4efc))
|
||||||
|
* simplify WaitForCleanup ([#1747](https://github.com/supabase/auth/issues/1747)) ([0084625](https://github.com/supabase/auth/commit/0084625ad0790dd7c14b412d932425f4b84bb4c8))
|
||||||
|
|
||||||
|
## [2.159.2](https://github.com/supabase/auth/compare/v2.159.1...v2.159.2) (2024-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow anonymous user to update password ([#1739](https://github.com/supabase/auth/issues/1739)) ([2d51956](https://github.com/supabase/auth/commit/2d519569d7b8540886d0a64bf3e561ef5f91eb63))
|
||||||
|
* hide hook name ([#1743](https://github.com/supabase/auth/issues/1743)) ([7e38f4c](https://github.com/supabase/auth/commit/7e38f4cf37768fe2adf92bbd0723d1d521b3d74c))
|
||||||
|
* remove server side cookie token methods ([#1742](https://github.com/supabase/auth/issues/1742)) ([c6efec4](https://github.com/supabase/auth/commit/c6efec4cbc950e01e1fd06d45ed821bd27c2ad08))
|
||||||
|
|
||||||
|
## [2.159.1](https://github.com/supabase/auth/compare/v2.159.0...v2.159.1) (2024-08-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* return oauth identity when user is created ([#1736](https://github.com/supabase/auth/issues/1736)) ([60cfb60](https://github.com/supabase/auth/commit/60cfb6063afa574dfe4993df6b0e087d4df71309))
|
||||||
|
|
||||||
|
## [2.159.0](https://github.com/supabase/auth/compare/v2.158.1...v2.159.0) (2024-08-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Vercel marketplace OIDC ([#1731](https://github.com/supabase/auth/issues/1731)) ([a9ff361](https://github.com/supabase/auth/commit/a9ff3612196af4a228b53a8bfb9c11785bcfba8d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add error codes to password login flow ([#1721](https://github.com/supabase/auth/issues/1721)) ([4351226](https://github.com/supabase/auth/commit/435122627a0784f1c5cb76d7e08caa1f6259423b))
|
||||||
|
* change phone constraint to per user ([#1713](https://github.com/supabase/auth/issues/1713)) ([b9bc769](https://github.com/supabase/auth/commit/b9bc769b93b6e700925fcbc1ebf8bf9678034205))
|
||||||
|
* custom SMS does not work with Twilio Verify ([#1733](https://github.com/supabase/auth/issues/1733)) ([dc2391d](https://github.com/supabase/auth/commit/dc2391d15f2c0725710aa388cd32a18797e6769c))
|
||||||
|
* ignore errors if transaction has closed already ([#1726](https://github.com/supabase/auth/issues/1726)) ([53c11d1](https://github.com/supabase/auth/commit/53c11d173a79ae5c004871b1b5840c6f9425a080))
|
||||||
|
* redirect invalid state errors to site url ([#1722](https://github.com/supabase/auth/issues/1722)) ([b2b1123](https://github.com/supabase/auth/commit/b2b11239dc9f9bd3c85d76f6c23ee94beb3330bb))
|
||||||
|
* remove TOTP field for phone enroll response ([#1717](https://github.com/supabase/auth/issues/1717)) ([4b04327](https://github.com/supabase/auth/commit/4b043275dd2d94600a8138d4ebf4638754ed926b))
|
||||||
|
* use signing jwk to sign oauth state ([#1728](https://github.com/supabase/auth/issues/1728)) ([66fd0c8](https://github.com/supabase/auth/commit/66fd0c8434388bbff1e1bf02f40517aca0e9d339))
|
||||||
|
|
||||||
|
## [2.158.1](https://github.com/supabase/auth/compare/v2.158.0...v2.158.1) (2024-08-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add last_challenged_at field to mfa factors ([#1705](https://github.com/supabase/auth/issues/1705)) ([29cbeb7](https://github.com/supabase/auth/commit/29cbeb799ff35ce528bfbd01b7103a24903d8061))
|
||||||
|
* allow enabling sms hook without setting up sms provider ([#1704](https://github.com/supabase/auth/issues/1704)) ([575e88a](https://github.com/supabase/auth/commit/575e88ac345adaeb76ab6aae077307fdab9cda3c))
|
||||||
|
* drop the MFA_ENABLED config ([#1701](https://github.com/supabase/auth/issues/1701)) ([078c3a8](https://github.com/supabase/auth/commit/078c3a8adcd51e57b68ab1b582549f5813cccd14))
|
||||||
|
* enforce uniqueness on verified phone numbers ([#1693](https://github.com/supabase/auth/issues/1693)) ([70446cc](https://github.com/supabase/auth/commit/70446cc11d70b0493d742fe03f272330bb5b633e))
|
||||||
|
* expose `X-Supabase-Api-Version` header in CORS ([#1612](https://github.com/supabase/auth/issues/1612)) ([6ccd814](https://github.com/supabase/auth/commit/6ccd814309dca70a9e3585543887194b05d725d3))
|
||||||
|
* include factor_id in query ([#1702](https://github.com/supabase/auth/issues/1702)) ([ac14e82](https://github.com/supabase/auth/commit/ac14e82b33545466184da99e99b9d3fe5f3876d9))
|
||||||
|
* move is owned by check to load factor ([#1703](https://github.com/supabase/auth/issues/1703)) ([701a779](https://github.com/supabase/auth/commit/701a779cf092e777dd4ad4954dc650164b09ab32))
|
||||||
|
* refactor TOTP MFA into separate methods ([#1698](https://github.com/supabase/auth/issues/1698)) ([250d92f](https://github.com/supabase/auth/commit/250d92f9a18d38089d1bf262ef9088022a446965))
|
||||||
|
* remove check for content-length ([#1700](https://github.com/supabase/auth/issues/1700)) ([81b332d](https://github.com/supabase/auth/commit/81b332d2f48622008469d2c5a9b130465a65f2a3))
|
||||||
|
* remove FindFactorsByUser ([#1707](https://github.com/supabase/auth/issues/1707)) ([af8e2dd](https://github.com/supabase/auth/commit/af8e2dda15a1234a05e7d2d34d316eaa029e0912))
|
||||||
|
* update openapi spec for MFA (Phone) ([#1689](https://github.com/supabase/auth/issues/1689)) ([a3da4b8](https://github.com/supabase/auth/commit/a3da4b89820c37f03ea128889616aca598d99f68))
|
||||||
|
|
||||||
|
## [2.158.0](https://github.com/supabase/auth/compare/v2.157.0...v2.158.0) (2024-07-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add hook log entry with `run_hook` action ([#1684](https://github.com/supabase/auth/issues/1684)) ([46491b8](https://github.com/supabase/auth/commit/46491b867a4f5896494417391392a373a453fa5f))
|
||||||
|
* MFA (Phone) ([#1668](https://github.com/supabase/auth/issues/1668)) ([ae091aa](https://github.com/supabase/auth/commit/ae091aa942bdc5bc97481037508ec3bb4079d859))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* maintain backward compatibility for asymmetric JWTs ([#1690](https://github.com/supabase/auth/issues/1690)) ([0ad1402](https://github.com/supabase/auth/commit/0ad1402444348e47e1e42be186b3f052d31be824))
|
||||||
|
* MFA NewFactor to default to creating unverfied factors ([#1692](https://github.com/supabase/auth/issues/1692)) ([3d448fa](https://github.com/supabase/auth/commit/3d448fa73cb77eb8511dbc47bfafecce4a4a2150))
|
||||||
|
* minor spelling errors ([#1688](https://github.com/supabase/auth/issues/1688)) ([6aca52b](https://github.com/supabase/auth/commit/6aca52b56f8a6254de7709c767b9a5649f1da248)), closes [#1682](https://github.com/supabase/auth/issues/1682)
|
||||||
|
* treat `GOTRUE_MFA_ENABLED` as meaning TOTP enabled on enroll and verify ([#1694](https://github.com/supabase/auth/issues/1694)) ([8015251](https://github.com/supabase/auth/commit/8015251400bd52cbdad3ea28afb83b1cdfe816dd))
|
||||||
|
* update mfa phone migration to be idempotent ([#1687](https://github.com/supabase/auth/issues/1687)) ([fdff1e7](https://github.com/supabase/auth/commit/fdff1e703bccf93217636266f1862bd0a9205edb))
|
||||||
|
|
||||||
|
## [2.157.0](https://github.com/supabase/auth/compare/v2.156.0...v2.157.0) (2024-07-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add asymmetric jwt support ([#1674](https://github.com/supabase/auth/issues/1674)) ([c7a2be3](https://github.com/supabase/auth/commit/c7a2be347b301b666e99adc3d3fed78c5e287c82))
|
||||||
|
|
||||||
|
## [2.156.0](https://github.com/supabase/auth/compare/v2.155.6...v2.156.0) (2024-07-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add is_anonymous claim to Auth hook jsonschema ([#1667](https://github.com/supabase/auth/issues/1667)) ([f9df65c](https://github.com/supabase/auth/commit/f9df65c91e226084abfa2e868ab6bab892d16d2f))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* restrict autoconfirm email change to anonymous users ([#1679](https://github.com/supabase/auth/issues/1679)) ([b57e223](https://github.com/supabase/auth/commit/b57e2230102280ed873acf70be1aeb5a2f6f7a4f))
|
||||||
|
|
||||||
|
## [2.155.6](https://github.com/supabase/auth/compare/v2.155.5...v2.155.6) (2024-07-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* use deep equal ([#1672](https://github.com/supabase/auth/issues/1672)) ([8efd57d](https://github.com/supabase/auth/commit/8efd57dab40346762a04bac61b314ce05d6fa69c))
|
||||||
|
|
||||||
|
## [2.155.5](https://github.com/supabase/auth/compare/v2.155.4...v2.155.5) (2024-07-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* check password max length in checkPasswordStrength ([#1659](https://github.com/supabase/auth/issues/1659)) ([1858c93](https://github.com/supabase/auth/commit/1858c93bba6f5bc41e4c65489f12c1a0786a1f2b))
|
||||||
|
* don't update attribute mapping if nil ([#1665](https://github.com/supabase/auth/issues/1665)) ([7e67f3e](https://github.com/supabase/auth/commit/7e67f3edbf81766df297a66f52a8e472583438c6))
|
||||||
|
* refactor mfa models and add observability to loadFactor ([#1669](https://github.com/supabase/auth/issues/1669)) ([822fb93](https://github.com/supabase/auth/commit/822fb93faab325ba3d4bb628dff43381d68d0b5d))
|
||||||
|
|
||||||
|
## [2.155.4](https://github.com/supabase/auth/compare/v2.155.3...v2.155.4) (2024-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* treat empty string as nil in `encrypted_password` ([#1663](https://github.com/supabase/auth/issues/1663)) ([f99286e](https://github.com/supabase/auth/commit/f99286eaed505daf3db6f381265ef6024e7e36d2))
|
||||||
|
|
||||||
|
## [2.155.3](https://github.com/supabase/auth/compare/v2.155.2...v2.155.3) (2024-07-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* serialize jwt as string ([#1657](https://github.com/supabase/auth/issues/1657)) ([98d8324](https://github.com/supabase/auth/commit/98d83245e40d606438eb0afdbf474276179fd91d))
|
||||||
|
|
||||||
|
## [2.155.2](https://github.com/supabase/auth/compare/v2.155.1...v2.155.2) (2024-07-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* improve session error logging ([#1655](https://github.com/supabase/auth/issues/1655)) ([5a6793e](https://github.com/supabase/auth/commit/5a6793ee8fce7a089750fe10b3b63bb0a19d6d21))
|
||||||
|
* omit empty string from name & use case-insensitive equality for comparing SAML attributes ([#1654](https://github.com/supabase/auth/issues/1654)) ([bf5381a](https://github.com/supabase/auth/commit/bf5381a6b1c686955dc4e39fe5fb806ffd309563))
|
||||||
|
* set rate limit log level to warn ([#1652](https://github.com/supabase/auth/issues/1652)) ([10ca9c8](https://github.com/supabase/auth/commit/10ca9c806e4b67a371897f1b3f93c515764c4240))
|
||||||
|
|
||||||
|
## [2.155.1](https://github.com/supabase/auth/compare/v2.155.0...v2.155.1) (2024-07-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* apply mailer autoconfirm config to update user email ([#1646](https://github.com/supabase/auth/issues/1646)) ([a518505](https://github.com/supabase/auth/commit/a5185058e72509b0781e0eb59910ecdbb8676fee))
|
||||||
|
* check for empty aud string ([#1649](https://github.com/supabase/auth/issues/1649)) ([42c1d45](https://github.com/supabase/auth/commit/42c1d4526b98203664d4a22c23014ecd0b4951f9))
|
||||||
|
* return proper error if sms rate limit is exceeded ([#1647](https://github.com/supabase/auth/issues/1647)) ([3c8d765](https://github.com/supabase/auth/commit/3c8d7656431ac4b2e80726b7c37adb8f0c778495))
|
||||||
|
|
||||||
|
## [2.155.0](https://github.com/supabase/auth/compare/v2.154.2...v2.155.0) (2024-07-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add `password_hash` and `id` fields to admin create user ([#1641](https://github.com/supabase/auth/issues/1641)) ([20d59f1](https://github.com/supabase/auth/commit/20d59f10b601577683d05bcd7d2128ff4bc462a0))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* improve mfa verify logs ([#1635](https://github.com/supabase/auth/issues/1635)) ([d8b47f9](https://github.com/supabase/auth/commit/d8b47f9d3f0dc8f97ad1de49e45f452ebc726481))
|
||||||
|
* invited users should have a temporary password generated ([#1644](https://github.com/supabase/auth/issues/1644)) ([3f70d9d](https://github.com/supabase/auth/commit/3f70d9d8974d0e9c437c51e1312ad17ce9056ec9))
|
||||||
|
* upgrade golang-jwt to v5 ([#1639](https://github.com/supabase/auth/issues/1639)) ([2cb97f0](https://github.com/supabase/auth/commit/2cb97f080fa4695766985cc4792d09476534be68))
|
||||||
|
* use pointer for `user.EncryptedPassword` ([#1637](https://github.com/supabase/auth/issues/1637)) ([bbecbd6](https://github.com/supabase/auth/commit/bbecbd61a46b0c528b1191f48d51f166c06f4b16))
|
||||||
|
|
||||||
|
## [2.154.2](https://github.com/supabase/auth/compare/v2.154.1...v2.154.2) (2024-06-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* publish to ghcr.io/supabase/auth ([#1626](https://github.com/supabase/auth/issues/1626)) ([930aa3e](https://github.com/supabase/auth/commit/930aa3edb633823d4510c2aff675672df06f1211)), closes [#1625](https://github.com/supabase/auth/issues/1625)
|
||||||
|
* revert define search path in auth functions ([#1634](https://github.com/supabase/auth/issues/1634)) ([155e87e](https://github.com/supabase/auth/commit/155e87ef8129366d665968f64d1fc66676d07e16))
|
||||||
|
* update MaxFrequency error message to reflect number of seconds ([#1540](https://github.com/supabase/auth/issues/1540)) ([e81c25d](https://github.com/supabase/auth/commit/e81c25d19551fdebfc5197d96bc220ddb0f8227b))
|
||||||
|
|
||||||
|
## [2.154.1](https://github.com/supabase/auth/compare/v2.154.0...v2.154.1) (2024-06-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add ip based limiter ([#1622](https://github.com/supabase/auth/issues/1622)) ([06464c0](https://github.com/supabase/auth/commit/06464c013571253d1f18f7ae5e840826c4bd84a7))
|
||||||
|
* admin user update should update is_anonymous field ([#1623](https://github.com/supabase/auth/issues/1623)) ([f5c6fcd](https://github.com/supabase/auth/commit/f5c6fcd9c3fee0f793f96880a8caebc5b5cb0916))
|
||||||
|
|
||||||
|
## [2.154.0](https://github.com/supabase/auth/compare/v2.153.0...v2.154.0) (2024-06-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add max length check for email ([#1508](https://github.com/supabase/auth/issues/1508)) ([f9c13c0](https://github.com/supabase/auth/commit/f9c13c0ad5c556bede49d3e0f6e5f58ca26161c3))
|
||||||
|
* add support for Slack OAuth V2 ([#1591](https://github.com/supabase/auth/issues/1591)) ([bb99251](https://github.com/supabase/auth/commit/bb992519cdf7578dc02cd7de55e2e6aa09b4c0f3))
|
||||||
|
* encrypt sensitive columns ([#1593](https://github.com/supabase/auth/issues/1593)) ([e4a4758](https://github.com/supabase/auth/commit/e4a475820b2dc1f985bd37df15a8ab9e781626f5))
|
||||||
|
* upgrade otel to v1.26 ([#1585](https://github.com/supabase/auth/issues/1585)) ([cdd13ad](https://github.com/supabase/auth/commit/cdd13adec02eb0c9401bc55a2915c1005d50dea1))
|
||||||
|
* use largest avatar from spotify instead ([#1210](https://github.com/supabase/auth/issues/1210)) ([4f9994b](https://github.com/supabase/auth/commit/4f9994bf792c3887f2f45910b11a9c19ee3a896b)), closes [#1209](https://github.com/supabase/auth/issues/1209)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* define search path in auth functions ([#1616](https://github.com/supabase/auth/issues/1616)) ([357bda2](https://github.com/supabase/auth/commit/357bda23cb2abd12748df80a9d27288aa548534d))
|
||||||
|
* enable rls & update grants for auth tables ([#1617](https://github.com/supabase/auth/issues/1617)) ([28967aa](https://github.com/supabase/auth/commit/28967aa4b5db2363cc581c9da0d64e974eb7b64c))
|
||||||
|
|
||||||
|
## [2.153.0](https://github.com/supabase/auth/compare/v2.152.0...v2.153.0) (2024-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add SAML specific external URL config ([#1599](https://github.com/supabase/auth/issues/1599)) ([b352719](https://github.com/supabase/auth/commit/b3527190560381fafe9ba2fae4adc3b73703024a))
|
||||||
|
* add support for verifying argon2i and argon2id passwords ([#1597](https://github.com/supabase/auth/issues/1597)) ([55409f7](https://github.com/supabase/auth/commit/55409f797bea55068a3fafdddd6cfdb78feba1b4))
|
||||||
|
* make the email client explicity set the format to be HTML ([#1149](https://github.com/supabase/auth/issues/1149)) ([53e223a](https://github.com/supabase/auth/commit/53e223abdf29f4abcad13f99baf00daedcb00c3f))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* call write header in write if not written ([#1598](https://github.com/supabase/auth/issues/1598)) ([0ef7eb3](https://github.com/supabase/auth/commit/0ef7eb30619d4c365e06a94a79b9cb0333d792da))
|
||||||
|
* deadlock issue with timeout middleware write ([#1595](https://github.com/supabase/auth/issues/1595)) ([6c9fbd4](https://github.com/supabase/auth/commit/6c9fbd4bd5623c729906fca7857ab508166a3056))
|
||||||
|
* improve token OIDC logging ([#1606](https://github.com/supabase/auth/issues/1606)) ([5262683](https://github.com/supabase/auth/commit/526268311844467664e89c8329e5aaee817dbbaf))
|
||||||
|
* update contributing to use v1.22 ([#1609](https://github.com/supabase/auth/issues/1609)) ([5894d9e](https://github.com/supabase/auth/commit/5894d9e41e7681512a9904ad47082a705e948c98))
|
||||||
|
|
||||||
|
## [2.152.0](https://github.com/supabase/auth/compare/v2.151.0...v2.152.0) (2024-05-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* new timeout writer implementation ([#1584](https://github.com/supabase/auth/issues/1584)) ([72614a1](https://github.com/supabase/auth/commit/72614a1fce27888f294772b512f8e31c55a36d87))
|
||||||
|
* remove legacy lookup in users for one_time_tokens (phase II) ([#1569](https://github.com/supabase/auth/issues/1569)) ([39ca026](https://github.com/supabase/auth/commit/39ca026035f6c61d206d31772c661b326c2a424c))
|
||||||
|
* update chi version ([#1581](https://github.com/supabase/auth/issues/1581)) ([c64ae3d](https://github.com/supabase/auth/commit/c64ae3dd775e8fb3022239252c31b4ee73893237))
|
||||||
|
* update openapi spec with identity and is_anonymous fields ([#1573](https://github.com/supabase/auth/issues/1573)) ([86a79df](https://github.com/supabase/auth/commit/86a79df9ecfcf09fda0b8e07afbc41154fbb7d9d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* improve logging structure ([#1583](https://github.com/supabase/auth/issues/1583)) ([c22fc15](https://github.com/supabase/auth/commit/c22fc15d2a8383e95a2364f383dfa7dce5f5df88))
|
||||||
|
* sms verify should update is_anonymous field ([#1580](https://github.com/supabase/auth/issues/1580)) ([e5f98cb](https://github.com/supabase/auth/commit/e5f98cb9e24ecebb0b7dc88c495fd456cc73fcba))
|
||||||
|
* use api_external_url domain as localname ([#1575](https://github.com/supabase/auth/issues/1575)) ([ed2b490](https://github.com/supabase/auth/commit/ed2b4907244281e4c54aaef74b1f4c8a8e3d97c9))
|
||||||
|
|
||||||
|
## [2.151.0](https://github.com/supabase/auth/compare/v2.150.1...v2.151.0) (2024-05-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* refactor one-time tokens for performance ([#1558](https://github.com/supabase/auth/issues/1558)) ([d1cf8d9](https://github.com/supabase/auth/commit/d1cf8d9096e9183d7772b73031de8ecbd66e912b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* do call send sms hook when SMS autoconfirm is enabled ([#1562](https://github.com/supabase/auth/issues/1562)) ([bfe4d98](https://github.com/supabase/auth/commit/bfe4d988f3768b0407526bcc7979fb21d8cbebb3))
|
||||||
|
* format test otps ([#1567](https://github.com/supabase/auth/issues/1567)) ([434a59a](https://github.com/supabase/auth/commit/434a59ae387c35fd6629ec7c674d439537e344e5))
|
||||||
|
* log final writer error instead of handling ([#1564](https://github.com/supabase/auth/issues/1564)) ([170bd66](https://github.com/supabase/auth/commit/170bd6615405afc852c7107f7358dfc837bad737))
|
||||||
|
|
||||||
|
## [2.150.1](https://github.com/supabase/auth/compare/v2.150.0...v2.150.1) (2024-04-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add db conn max idle time setting ([#1555](https://github.com/supabase/auth/issues/1555)) ([2caa7b4](https://github.com/supabase/auth/commit/2caa7b4d75d2ff54af20f3e7a30a8eeec8cbcda9))
|
||||||
|
|
||||||
|
## [2.150.0](https://github.com/supabase/auth/compare/v2.149.0...v2.150.0) (2024-04-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add support for Azure CIAM login ([#1541](https://github.com/supabase/auth/issues/1541)) ([1cb4f96](https://github.com/supabase/auth/commit/1cb4f96bdc7ef3ef995781b4cf3c4364663a2bf3))
|
||||||
|
* add timeout middleware ([#1529](https://github.com/supabase/auth/issues/1529)) ([f96ff31](https://github.com/supabase/auth/commit/f96ff31040b28e3a7373b4fd41b7334eda1b413e))
|
||||||
|
* allow for postgres and http functions on each extensibility point ([#1528](https://github.com/supabase/auth/issues/1528)) ([348a1da](https://github.com/supabase/auth/commit/348a1daee24f6e44b14c018830b748e46d34b4c2))
|
||||||
|
* merge provider metadata on link account ([#1552](https://github.com/supabase/auth/issues/1552)) ([bd8b5c4](https://github.com/supabase/auth/commit/bd8b5c41dd544575e1a52ccf1ef3f0fdee67458c))
|
||||||
|
* send over user in SendSMS Hook instead of UserID ([#1551](https://github.com/supabase/auth/issues/1551)) ([d4d743c](https://github.com/supabase/auth/commit/d4d743c2ae9490e1b3249387e3b0d60df6913c68))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* return error if session id does not exist ([#1538](https://github.com/supabase/auth/issues/1538)) ([91e9eca](https://github.com/supabase/auth/commit/91e9ecabe33a1c022f8e82a6050c22a7ca42de48))
|
||||||
|
|
||||||
|
## [2.149.0](https://github.com/supabase/auth/compare/v2.148.0...v2.149.0) (2024-04-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* refactor generate accesss token to take in request ([#1531](https://github.com/supabase/auth/issues/1531)) ([e4f2b59](https://github.com/supabase/auth/commit/e4f2b59e8e1f8158b6461a384349f1a32cc1bf9a))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* linkedin_oidc provider error ([#1534](https://github.com/supabase/auth/issues/1534)) ([4f5e8e5](https://github.com/supabase/auth/commit/4f5e8e5120531e5a103fbdda91b51cabcb4e1a8c))
|
||||||
|
* revert patch for linkedin_oidc provider error ([#1535](https://github.com/supabase/auth/issues/1535)) ([58ef4af](https://github.com/supabase/auth/commit/58ef4af0b4224b78cd9e59428788d16a8d31e562))
|
||||||
|
* update linkedin issuer url ([#1536](https://github.com/supabase/auth/issues/1536)) ([10d6d8b](https://github.com/supabase/auth/commit/10d6d8b1eafa504da2b2a351d1f64a3a832ab1b9))
|
||||||
|
|
||||||
|
## [2.148.0](https://github.com/supabase/auth/compare/v2.147.1...v2.148.0) (2024-04-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add array attribute mapping for SAML ([#1526](https://github.com/supabase/auth/issues/1526)) ([7326285](https://github.com/supabase/auth/commit/7326285c8af5c42e5c0c2d729ab224cf33ac3a1f))
|
||||||
|
|
||||||
|
## [2.147.1](https://github.com/supabase/auth/compare/v2.147.0...v2.147.1) (2024-04-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add validation and proper decoding on send email hook ([#1520](https://github.com/supabase/auth/issues/1520)) ([e19e762](https://github.com/supabase/auth/commit/e19e762e3e29729a1d1164c65461427822cc87f1))
|
||||||
|
* remove deprecated LogoutAllRefreshTokens ([#1519](https://github.com/supabase/auth/issues/1519)) ([35533ea](https://github.com/supabase/auth/commit/35533ea100669559e1209ecc7b091db3657234d9))
|
||||||
|
|
||||||
|
## [2.147.0](https://github.com/supabase/auth/compare/v2.146.0...v2.147.0) (2024-04-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add send email Hook ([#1512](https://github.com/supabase/auth/issues/1512)) ([cf42e02](https://github.com/supabase/auth/commit/cf42e02ec63779f52b1652a7413f64994964c82d))
|
||||||
|
|
||||||
|
## [2.146.0](https://github.com/supabase/auth/compare/v2.145.0...v2.146.0) (2024-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add custom sms hook ([#1474](https://github.com/supabase/auth/issues/1474)) ([0f6b29a](https://github.com/supabase/auth/commit/0f6b29a46f1dcbf92aa1f7cb702f42e7640f5f93))
|
||||||
|
* forbid generating an access token without a session ([#1504](https://github.com/supabase/auth/issues/1504)) ([795e93d](https://github.com/supabase/auth/commit/795e93d0afbe94bcd78489a3319a970b7bf8e8bc))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add cleanup statement for anonymous users ([#1497](https://github.com/supabase/auth/issues/1497)) ([cf2372a](https://github.com/supabase/auth/commit/cf2372a177796b829b72454e7491ce768bf5a42f))
|
||||||
|
* generate signup link should not error ([#1514](https://github.com/supabase/auth/issues/1514)) ([4fc3881](https://github.com/supabase/auth/commit/4fc388186ac7e7a9a32ca9b963a83d6ac2eb7603))
|
||||||
|
* move all EmailActionTypes to mailer package ([#1510](https://github.com/supabase/auth/issues/1510)) ([765db08](https://github.com/supabase/auth/commit/765db08582669a1b7f054217fa8f0ed45804c0b5))
|
||||||
|
* refactor mfa and aal update methods ([#1503](https://github.com/supabase/auth/issues/1503)) ([31a5854](https://github.com/supabase/auth/commit/31a585429bf248aa919d94c82c7c9e0c1c695461))
|
||||||
|
* rename from CustomSMSProvider to SendSMS ([#1513](https://github.com/supabase/auth/issues/1513)) ([c0bc37b](https://github.com/supabase/auth/commit/c0bc37b44effaebb62ba85102f072db07fe57e48))
|
||||||
|
|
||||||
|
## [2.145.0](https://github.com/supabase/gotrue/compare/v2.144.0...v2.145.0) (2024-03-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add error codes ([#1377](https://github.com/supabase/gotrue/issues/1377)) ([e4beea1](https://github.com/supabase/gotrue/commit/e4beea1cdb80544b0581f1882696a698fdf64938))
|
||||||
|
* add kakao OIDC ([#1381](https://github.com/supabase/gotrue/issues/1381)) ([b5566e7](https://github.com/supabase/gotrue/commit/b5566e7ac001cc9f2bac128de0fcb908caf3a5ed))
|
||||||
|
* clean up expired factors ([#1371](https://github.com/supabase/gotrue/issues/1371)) ([5c94207](https://github.com/supabase/gotrue/commit/5c9420743a9aef0675f823c30aa4525b4933836e))
|
||||||
|
* configurable NameID format for SAML provider ([#1481](https://github.com/supabase/gotrue/issues/1481)) ([ef405d8](https://github.com/supabase/gotrue/commit/ef405d89e69e008640f275bc37f8ec02ad32da40))
|
||||||
|
* HTTP Hook - Add custom envconfig decoding for HTTP Hook Secrets ([#1467](https://github.com/supabase/gotrue/issues/1467)) ([5b24c4e](https://github.com/supabase/gotrue/commit/5b24c4eb05b2b52c4177d5f41cba30cb68495c8c))
|
||||||
|
* refactor PKCE FlowState to reduce duplicate code ([#1446](https://github.com/supabase/gotrue/issues/1446)) ([b8d0337](https://github.com/supabase/gotrue/commit/b8d0337922c6712380f6dc74f7eac9fb71b1ae48))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add http support for https hooks on localhost ([#1484](https://github.com/supabase/gotrue/issues/1484)) ([5c04104](https://github.com/supabase/gotrue/commit/5c04104bf77a9c2db46d009764ec3ec3e484fc09))
|
||||||
|
* cleanup panics due to bad inactivity timeout code ([#1471](https://github.com/supabase/gotrue/issues/1471)) ([548edf8](https://github.com/supabase/gotrue/commit/548edf898161c9ba9a136fc99ec2d52a8ba1f856))
|
||||||
|
* **docs:** remove bracket on file name for broken link ([#1493](https://github.com/supabase/gotrue/issues/1493)) ([96f7a68](https://github.com/supabase/gotrue/commit/96f7a68a5479825e31106c2f55f82d5b2c007c0f))
|
||||||
|
* impose expiry on auth code instead of magic link ([#1440](https://github.com/supabase/gotrue/issues/1440)) ([35aeaf1](https://github.com/supabase/gotrue/commit/35aeaf1b60dd27a22662a6d1955d60cc907b55dd))
|
||||||
|
* invalidate email, phone OTPs on password change ([#1489](https://github.com/supabase/gotrue/issues/1489)) ([960a4f9](https://github.com/supabase/gotrue/commit/960a4f94f5500e33a0ec2f6afe0380bbc9562500))
|
||||||
|
* move creation of flow state into function ([#1470](https://github.com/supabase/gotrue/issues/1470)) ([4392a08](https://github.com/supabase/gotrue/commit/4392a08d68d18828005d11382730117a7b143635))
|
||||||
|
* prevent user email side-channel leak on verify ([#1472](https://github.com/supabase/gotrue/issues/1472)) ([311cde8](https://github.com/supabase/gotrue/commit/311cde8d1e82f823ae26a341e068034d60273864))
|
||||||
|
* refactor email sending functions ([#1495](https://github.com/supabase/gotrue/issues/1495)) ([285c290](https://github.com/supabase/gotrue/commit/285c290adf231fea7ca1dff954491dc427cf18e2))
|
||||||
|
* refactor factor_test to centralize setup ([#1473](https://github.com/supabase/gotrue/issues/1473)) ([c86007e](https://github.com/supabase/gotrue/commit/c86007e59684334b5e8c2285c36094b6eec89442))
|
||||||
|
* refactor mfa challenge and tests ([#1469](https://github.com/supabase/gotrue/issues/1469)) ([6c76f21](https://github.com/supabase/gotrue/commit/6c76f21cee5dbef0562c37df6a546939affb2f8d))
|
||||||
|
* Resend SMS when duplicate SMS sign ups are made ([#1490](https://github.com/supabase/gotrue/issues/1490)) ([73240a0](https://github.com/supabase/gotrue/commit/73240a0b096977703e3c7d24a224b5641ce47c81))
|
||||||
|
* unlink identity bugs ([#1475](https://github.com/supabase/gotrue/issues/1475)) ([73e8d87](https://github.com/supabase/gotrue/commit/73e8d8742de3575b3165a707b5d2f486b2598d9d))
|
||||||
|
|
||||||
|
## [2.144.0](https://github.com/supabase/gotrue/compare/v2.143.0...v2.144.0) (2024-03-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add configuration for custom sms sender hook ([#1428](https://github.com/supabase/gotrue/issues/1428)) ([1ea56b6](https://github.com/supabase/gotrue/commit/1ea56b62d47edb0766d9e445406ecb43d387d920))
|
||||||
|
* anonymous sign-ins ([#1460](https://github.com/supabase/gotrue/issues/1460)) ([130df16](https://github.com/supabase/gotrue/commit/130df165270c69c8e28aaa1b9421342f997c1ff3))
|
||||||
|
* clean up test setup in MFA tests ([#1452](https://github.com/supabase/gotrue/issues/1452)) ([7185af8](https://github.com/supabase/gotrue/commit/7185af8de4a269cdde2629054d222333d3522ebe))
|
||||||
|
* pass transaction to `invokeHook`, fixing pool exhaustion ([#1465](https://github.com/supabase/gotrue/issues/1465)) ([b536d36](https://github.com/supabase/gotrue/commit/b536d368f35adb31f937169e3f093d28352fa7be))
|
||||||
|
* refactor resource owner password grant ([#1443](https://github.com/supabase/gotrue/issues/1443)) ([e63ad6f](https://github.com/supabase/gotrue/commit/e63ad6ff0f67d9a83456918a972ecb5109125628))
|
||||||
|
* use dummy instance id to improve performance on refresh token queries ([#1454](https://github.com/supabase/gotrue/issues/1454)) ([656474e](https://github.com/supabase/gotrue/commit/656474e1b9ff3d5129190943e8c48e456625afe5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* expose `provider` under `amr` in access token ([#1456](https://github.com/supabase/gotrue/issues/1456)) ([e9f38e7](https://github.com/supabase/gotrue/commit/e9f38e76d8a7b93c5c2bb0de918a9b156155f018))
|
||||||
|
* improve MFA QR Code resilience so as to support providers like 1Password ([#1455](https://github.com/supabase/gotrue/issues/1455)) ([6522780](https://github.com/supabase/gotrue/commit/652278046c9dd92f5cecd778735b058ef3fb41c7))
|
||||||
|
* refactor request params to use generics ([#1464](https://github.com/supabase/gotrue/issues/1464)) ([e1cdf5c](https://github.com/supabase/gotrue/commit/e1cdf5c4b5c1bf467094f4bdcaa2e42a5cc51c20))
|
||||||
|
* revert refactor resource owner password grant ([#1466](https://github.com/supabase/gotrue/issues/1466)) ([fa21244](https://github.com/supabase/gotrue/commit/fa21244fa929709470c2e1fc4092a9ce947399e7))
|
||||||
|
* update file name so migration to Drop IP Address is applied ([#1447](https://github.com/supabase/gotrue/issues/1447)) ([f29e89d](https://github.com/supabase/gotrue/commit/f29e89d7d2c48ee8fd5bf8279a7fa3db0ad4d842))
|
||||||
|
|
||||||
|
## [2.143.0](https://github.com/supabase/gotrue/compare/v2.142.0...v2.143.0) (2024-02-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* calculate aal without transaction ([#1437](https://github.com/supabase/gotrue/issues/1437)) ([8dae661](https://github.com/supabase/gotrue/commit/8dae6614f1a2b58819f94894cef01e9f99117769))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* deprecate hooks ([#1421](https://github.com/supabase/gotrue/issues/1421)) ([effef1b](https://github.com/supabase/gotrue/commit/effef1b6ecc448b7927eff23df8d5b509cf16b5c))
|
||||||
|
* error should be an IsNotFoundError ([#1432](https://github.com/supabase/gotrue/issues/1432)) ([7f40047](https://github.com/supabase/gotrue/commit/7f40047aec3577d876602444b1d88078b2237d66))
|
||||||
|
* populate password verification attempt hook ([#1436](https://github.com/supabase/gotrue/issues/1436)) ([f974bdb](https://github.com/supabase/gotrue/commit/f974bdb58340395955ca27bdd26d57062433ece9))
|
||||||
|
* restrict mfa enrollment to aal2 if verified factors are present ([#1439](https://github.com/supabase/gotrue/issues/1439)) ([7e10d45](https://github.com/supabase/gotrue/commit/7e10d45e54010d38677f4c3f2f224127688eb9a2))
|
||||||
|
* update phone if autoconfirm is enabled ([#1431](https://github.com/supabase/gotrue/issues/1431)) ([95db770](https://github.com/supabase/gotrue/commit/95db770c5d2ecca4a1e960a8cb28ded37cccc100))
|
||||||
|
* use email change email in identity ([#1429](https://github.com/supabase/gotrue/issues/1429)) ([4d3b9b8](https://github.com/supabase/gotrue/commit/4d3b9b8841b1a5fa8f3244825153cc81a73ba300))
|
||||||
|
|
||||||
|
## [2.142.0](https://github.com/supabase/gotrue/compare/v2.141.0...v2.142.0) (2024-02-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* alter tag to use raw ([#1427](https://github.com/supabase/gotrue/issues/1427)) ([53cfe5d](https://github.com/supabase/gotrue/commit/53cfe5de57d4b5ab6e8e2915493856ecd96f4ede))
|
||||||
|
* update README.md to trigger release ([#1425](https://github.com/supabase/gotrue/issues/1425)) ([91e0e24](https://github.com/supabase/gotrue/commit/91e0e245f5957ebce13370f79fd4a6be8108ed80))
|
||||||
|
|
||||||
|
## [2.141.0](https://github.com/supabase/gotrue/compare/v2.140.0...v2.141.0) (2024-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* drop sha hash tag ([#1422](https://github.com/supabase/gotrue/issues/1422)) ([76853ce](https://github.com/supabase/gotrue/commit/76853ce6d45064de5608acc8100c67a8337ba791))
|
||||||
|
* prefix release with v ([#1424](https://github.com/supabase/gotrue/issues/1424)) ([9d398cd](https://github.com/supabase/gotrue/commit/9d398cd75fca01fb848aa88b4f545552e8b5751a))
|
||||||
|
|
||||||
|
## [2.140.0](https://github.com/supabase/gotrue/compare/v2.139.2...v2.140.0) (2024-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* deprecate existing webhook implementation ([#1417](https://github.com/supabase/gotrue/issues/1417)) ([5301e48](https://github.com/supabase/gotrue/commit/5301e481b0c7278c18b4578a5b1aa8d2256c2f5d))
|
||||||
|
* update publish.yml checkout repository so there is access to Dockerfile ([#1419](https://github.com/supabase/gotrue/issues/1419)) ([7cce351](https://github.com/supabase/gotrue/commit/7cce3518e8c9f1f3f93e4f6a0658ee08771c4f1c))
|
||||||
|
|
||||||
|
## [2.139.2](https://github.com/supabase/gotrue/compare/v2.139.1...v2.139.2) (2024-02-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* improve perf in account linking ([#1394](https://github.com/supabase/gotrue/issues/1394)) ([8eedb95](https://github.com/supabase/gotrue/commit/8eedb95dbaa310aac464645ec91d6a374813ab89))
|
||||||
|
* OIDC provider validation log message ([#1380](https://github.com/supabase/gotrue/issues/1380)) ([27e6b1f](https://github.com/supabase/gotrue/commit/27e6b1f9a4394c5c4f8dff9a8b5529db1fc67af9))
|
||||||
|
* only create or update the email / phone identity after it's been verified ([#1403](https://github.com/supabase/gotrue/issues/1403)) ([2d20729](https://github.com/supabase/gotrue/commit/2d207296ec22dd6c003c89626d255e35441fd52d))
|
||||||
|
* only create or update the email / phone identity after it's been verified (again) ([#1409](https://github.com/supabase/gotrue/issues/1409)) ([bc6a5b8](https://github.com/supabase/gotrue/commit/bc6a5b884b43fe6b8cb924d3f79999fe5bfe7c5f))
|
||||||
|
* unmarshal is_private_email correctly ([#1402](https://github.com/supabase/gotrue/issues/1402)) ([47df151](https://github.com/supabase/gotrue/commit/47df15113ce8d86666c0aba3854954c24fe39f7f))
|
||||||
|
* use `pattern` for semver docker image tags ([#1411](https://github.com/supabase/gotrue/issues/1411)) ([14a3aeb](https://github.com/supabase/gotrue/commit/14a3aeb6c3f46c8d38d98cc840112dfd0278eeda))
|
||||||
|
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* "fix: only create or update the email / phone identity after i… ([#1407](https://github.com/supabase/gotrue/issues/1407)) ([ff86849](https://github.com/supabase/gotrue/commit/ff868493169a0d9ac18b66058a735197b1df5b9b))
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
* @supabase/auth
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
In the interest of fostering an open and welcoming environment, we as
|
||||||
|
contributors and maintainers pledge to making participation in our project and
|
||||||
|
our community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity and
|
||||||
|
orientation.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to creating a positive environment
|
||||||
|
include:
|
||||||
|
|
||||||
|
* Using welcoming and inclusive language
|
||||||
|
* Being respectful of differing viewpoints and experiences
|
||||||
|
* Gracefully accepting constructive criticism
|
||||||
|
* Focusing on what is best for the community
|
||||||
|
* Showing empathy towards other community members
|
||||||
|
|
||||||
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||||
|
advances
|
||||||
|
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or electronic
|
||||||
|
address, without explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Our Responsibilities
|
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying the standards of acceptable
|
||||||
|
behavior and are expected to take appropriate and fair corrective action in
|
||||||
|
response to any instances of unacceptable behavior.
|
||||||
|
|
||||||
|
Project maintainers have the right and responsibility to remove, edit, or
|
||||||
|
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||||
|
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||||
|
permanently any contributor for other behaviors that they deem inappropriate,
|
||||||
|
threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies both within project spaces and in public spaces
|
||||||
|
when an individual is representing the project or its community. Examples of
|
||||||
|
representing a project or community include using an official project e-mail
|
||||||
|
address, posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event. Representation of a project may be
|
||||||
|
further defined and clarified by project maintainers.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported by contacting the project team at david@netlify.com. All
|
||||||
|
complaints will be reviewed and investigated and will result in a response that
|
||||||
|
is deemed necessary and appropriate to the circumstances. The project team is
|
||||||
|
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||||
|
Further details of specific enforcement policies may be posted separately.
|
||||||
|
|
||||||
|
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||||
|
faith may face temporary or permanent repercussions as determined by other
|
||||||
|
members of the project's leadership.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||||
|
available at [http://contributor-covenant.org/version/1/4][version]
|
||||||
|
|
||||||
|
[homepage]: http://contributor-covenant.org
|
||||||
|
[version]: http://contributor-covenant.org/version/1/4/
|
||||||
|
|
@ -0,0 +1,523 @@
|
||||||
|
# CONTRIBUTING
|
||||||
|
|
||||||
|
We would love to have contributions from each and every one of you in the community be it big or small and you are the ones who motivate us to do better than what we do today.
|
||||||
|
|
||||||
|
## Code Of Conduct
|
||||||
|
|
||||||
|
Please help us keep all our projects open and inclusive. Kindly follow our [Code of Conduct](CODE_OF_CONDUCT.md) to keep the ecosystem healthy and friendly for all.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Auth has a development container setup that makes it easy to get started contributing. This setup only requires that [Docker](https://www.docker.com/get-started) is setup on your system. The development container setup includes a PostgreSQL container with migrations already applied and a container running GoTrue that will perform a hot reload when changes to the source code are detected.
|
||||||
|
|
||||||
|
If you would like to run Auth locally or learn more about what these containers are doing for you, continue reading the [Setup and Tooling](#setup-and-tooling) section below. Otherwise, you can skip ahead to the [How To Verify that GoTrue is Available](#how-to-verify-that-auth-is-available) section to learn about working with and developing GoTrue.
|
||||||
|
|
||||||
|
Before using the containers, you will need to make sure an `.env.docker` file exists by making a copy of `example.docker.env` and configuring it for your needs. The set of env vars in `example.docker.env` only contain the necessary env vars for auth to start in a docker environment. For the full list of env vars, please refer to `example.env` and copy over the necessary ones into your `.env.docker` file.
|
||||||
|
|
||||||
|
The following are some basic commands. A full and up to date list of commands can be found in the project's `Makefile` or by running `make help`.
|
||||||
|
|
||||||
|
### Starting the containers
|
||||||
|
|
||||||
|
Start the containers as described above in an attached state with log output.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running tests in the containers
|
||||||
|
|
||||||
|
Start the containers with a fresh database and run the project's tests.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing the containers
|
||||||
|
|
||||||
|
Remove both containers and their volumes. This removes any data associated with the containers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild the containers
|
||||||
|
|
||||||
|
Fully rebuild the containers without using any cached layers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup and Tooling
|
||||||
|
|
||||||
|
Auth -- as the name implies -- is a user registration and authentication API developed in [Go](https://go.dev).
|
||||||
|
|
||||||
|
It connects to a [PostgreSQL](https://www.postgresql.org) database in order to store authentication data, [Soda CLI](https://gobuffalo.io/en/docs/db/toolbox) to manage database schema and migrations,
|
||||||
|
and runs inside a [Docker](https://www.docker.com/get-started) container.
|
||||||
|
|
||||||
|
Therefore, to contribute to Auth you will need to install these tools.
|
||||||
|
|
||||||
|
### Install Tools
|
||||||
|
|
||||||
|
- Install [Go](https://go.dev) 1.22
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Via Homebrew on macOS
|
||||||
|
brew install go@1.22
|
||||||
|
|
||||||
|
# Set the environment variable in the ~/.zshrc file
|
||||||
|
echo 'export PATH="/opt/homebrew/opt/go@1.22/bin:$PATH"' >> ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
- Install [Docker](https://www.docker.com/get-started)
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Via Homebrew on macOS
|
||||||
|
brew install docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, if you prefer, download [Docker Desktop](https://www.docker.com/get-started).
|
||||||
|
|
||||||
|
- Install [Soda CLI](https://gobuffalo.io/en/docs/db/toolbox)
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Via Homebrew on macOS
|
||||||
|
brew install gobuffalo/tap/pop
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are on macOS Catalina you may [run into issues installing Soda with Brew](https://github.com/gobuffalo/homebrew-tap/issues/5). Do check your `GOPATH` and run
|
||||||
|
|
||||||
|
`go build -o /bin/soda github.com/gobuffalo/pop/soda` to resolve.
|
||||||
|
|
||||||
|
- Clone the Auth [repository](https://github.com/supabase/auth)
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
git clone https://github.com/supabase/auth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Auth
|
||||||
|
|
||||||
|
To begin installation, be sure to start from the root directory.
|
||||||
|
|
||||||
|
- `cd auth`
|
||||||
|
|
||||||
|
To complete installation, you will:
|
||||||
|
|
||||||
|
- Install the PostgreSQL Docker image
|
||||||
|
- Create the DB Schema and Migrations
|
||||||
|
- Setup a local `.env` for environment variables
|
||||||
|
- Compile Auth
|
||||||
|
- Run the Auth binary executable
|
||||||
|
|
||||||
|
#### Installation Steps
|
||||||
|
|
||||||
|
1. Start Docker
|
||||||
|
2. To install the PostgreSQL Docker image, run:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Builds the postgres image
|
||||||
|
docker-compose -f docker-compose-dev.yml build postgres
|
||||||
|
|
||||||
|
# Runs the postgres container
|
||||||
|
docker-compose -f docker-compose-dev.yml up postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
You should then see in Docker that `auth_postgresql` is running on `port: 5432`.
|
||||||
|
|
||||||
|
> **Important** If you happen to already have a local running instance of Postgres running on the port `5432` because you
|
||||||
|
> may have installed via [homebrew on macOS](https://formulae.brew.sh/formula/postgresql) then be certain to stop the process using:
|
||||||
|
>
|
||||||
|
> - `brew services stop postgresql`
|
||||||
|
>
|
||||||
|
> If you need to run the test environment on another port, you will need to modify several configuration files to use a different custom port.
|
||||||
|
|
||||||
|
3. Next compile the Auth binary:
|
||||||
|
|
||||||
|
When you fork a repository, GitHub does not automatically copy all the tags (tags are not included by default). To ensure the correct tag is set before building the binary, you need to fetch the tags from the upstream repository and push them to your fork. Follow these steps:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Fetch the tags from the upstream repository
|
||||||
|
git fetch upstream --tags
|
||||||
|
|
||||||
|
# Push the tags to your fork
|
||||||
|
git push origin --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build the binary by running:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. To setup the database schema via Soda, run:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
make migrate_test
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see log messages that indicate that the Auth migrations were applied successfully:
|
||||||
|
|
||||||
|
```terminal
|
||||||
|
INFO[0000] Auth migrations applied successfully
|
||||||
|
DEBU[0000] after status
|
||||||
|
[POP] 2021/12/15 10:44:36 sql - SELECT EXISTS (SELECT schema_migrations.* FROM schema_migrations AS schema_migrations WHERE version = $1) | ["20210710035447"]
|
||||||
|
[POP] 2021/12/15 10:44:36 sql - SELECT EXISTS (SELECT schema_migrations.* FROM schema_migrations AS schema_migrations WHERE version = $1) | ["20210722035447"]
|
||||||
|
[POP] 2021/12/15 10:44:36 sql - SELECT EXISTS (SELECT schema_migrations.* FROM schema_migrations AS schema_migrations WHERE version = $1) | ["20210730183235"]
|
||||||
|
[POP] 2021/12/15 10:44:36 sql - SELECT EXISTS (SELECT schema_migrations.* FROM schema_migrations AS schema_migrations WHERE version = $1) | ["20210909172000"]
|
||||||
|
[POP] 2021/12/15 10:44:36 sql - SELECT EXISTS (SELECT schema_migrations.* FROM schema_migrations AS schema_migrations WHERE version = $1) | ["20211122151130"]
|
||||||
|
Version Name Status
|
||||||
|
20210710035447 alter_users Applied
|
||||||
|
20210722035447 adds_confirmed_at Applied
|
||||||
|
20210730183235 add_email_change_confirmed Applied
|
||||||
|
20210909172000 create_identities_table Applied
|
||||||
|
20211122151130 create_user_id_idx Applied
|
||||||
|
```
|
||||||
|
|
||||||
|
That lists each migration that was applied. Note: there may be more migrations than those listed.
|
||||||
|
|
||||||
|
4. Create a `.env` file in the root of the project and copy the following config in [example.env](example.env). Set the values to GOTRUE_SMS_TEST_OTP_VALID_UNTIL in the `.env` file.
|
||||||
|
|
||||||
|
5. In order to have Auth connect to your PostgreSQL database running in Docker, it is important to set a connection string like:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Important: Auth requires a set of SMTP credentials to run, you can generate your own SMTP credentials via an SMTP provider such as AWS SES, SendGrid, MailChimp, SendInBlue or any other SMTP providers.
|
||||||
|
|
||||||
|
6. Then finally Start Auth
|
||||||
|
7. Verify that Auth is Available
|
||||||
|
|
||||||
|
### Starting Auth
|
||||||
|
|
||||||
|
Start Auth by running the executable:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
./auth
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will re-run migrations and then indicate that Auth has started:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
INFO[0000] Auth API started on: localhost:9999
|
||||||
|
```
|
||||||
|
|
||||||
|
### How To Verify that Auth is Available
|
||||||
|
|
||||||
|
To test that your Auth is up and available, you can query the `health` endpoint at `http://localhost:9999/health`. You should see a response similar to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "Auth is a user registration and authentication API",
|
||||||
|
"name": "Auth",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To see the current settings, make a request to `http://localhost:9999/settings` and you should see a response similar to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"external": {
|
||||||
|
"apple": false,
|
||||||
|
"azure": false,
|
||||||
|
"bitbucket": false,
|
||||||
|
"discord": false,
|
||||||
|
"github": false,
|
||||||
|
"gitlab": false,
|
||||||
|
"google": false,
|
||||||
|
"facebook": false,
|
||||||
|
"spotify": false,
|
||||||
|
"slack": false,
|
||||||
|
"slack_oidc": false,
|
||||||
|
"twitch": true,
|
||||||
|
"twitter": false,
|
||||||
|
"email": true,
|
||||||
|
"phone": false,
|
||||||
|
"saml": false
|
||||||
|
},
|
||||||
|
"external_labels": {
|
||||||
|
"saml": "auth0"
|
||||||
|
},
|
||||||
|
"disable_signup": false,
|
||||||
|
"mailer_autoconfirm": false,
|
||||||
|
"phone_autoconfirm": false,
|
||||||
|
"sms_provider": "twilio"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Use Admin API Endpoints
|
||||||
|
|
||||||
|
To test the admin endpoints (or other api endpoints), you can invoke via HTTP requests. Using [Insomnia](https://insomnia.rest/products/insomnia) can help you issue these requests.
|
||||||
|
|
||||||
|
You will need to know the `GOTRUE_JWT_SECRET` configured in the `.env` settings.
|
||||||
|
|
||||||
|
Also, you must generate a JWT with the signature which has the `supabase_admin` role (or one that is specified in `GOTRUE_JWT_ADMIN_ROLES`).
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "supabase_admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can sign this payload using the [JWT.io Debugger](https://jwt.io/#debugger-io) but make sure that `secret base64 encoded` is unchecked.
|
||||||
|
|
||||||
|
Then you can use this JWT as a Bearer token for admin requests.
|
||||||
|
|
||||||
|
### Create User (aka Sign Up a User)
|
||||||
|
|
||||||
|
To create a new user, `POST /admin/users` with the payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "12345678"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/users HTTP/1.1
|
||||||
|
Host: localhost:9999
|
||||||
|
User-Agent: insomnia/2021.7.2
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <YOUR_SIGNED_JWT>
|
||||||
|
Accept: */*
|
||||||
|
Content-Length: 57
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
And you should get a new user:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "e78c512d-68e4-482b-901b-75003e89acae",
|
||||||
|
"aud": "authenticated",
|
||||||
|
"role": "authenticated",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"app_metadata": {
|
||||||
|
"provider": "email",
|
||||||
|
"providers": ["email"]
|
||||||
|
},
|
||||||
|
"user_metadata": {},
|
||||||
|
"identities": null,
|
||||||
|
"created_at": "2021-12-15T12:40:03.507551-05:00",
|
||||||
|
"updated_at": "2021-12-15T12:40:03.512067-05:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List/Find Users
|
||||||
|
|
||||||
|
To create a new user, make a request to `GET /admin/users`.
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/users HTTP/1.1
|
||||||
|
Host: localhost:9999
|
||||||
|
User-Agent: insomnia/2021.7.2
|
||||||
|
Authorization: Bearer <YOUR*SIGNED_JWT>
|
||||||
|
Accept: */\_
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
The response from `/admin/users` should return all users:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aud": "authenticated",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "b7fd0253-6e16-4d4e-b61b-5943cb1b2102",
|
||||||
|
"aud": "authenticated",
|
||||||
|
"role": "authenticated",
|
||||||
|
"email": "user+4@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"app_metadata": {
|
||||||
|
"provider": "email",
|
||||||
|
"providers": ["email"]
|
||||||
|
},
|
||||||
|
"user_metadata": {},
|
||||||
|
"identities": null,
|
||||||
|
"created_at": "2021-12-15T12:43:58.12207-05:00",
|
||||||
|
"updated_at": "2021-12-15T12:43:58.122073-05:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d69ae847-99be-4642-868f-439c2cdd9af4",
|
||||||
|
"aud": "authenticated",
|
||||||
|
"role": "authenticated",
|
||||||
|
"email": "user+3@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"app_metadata": {
|
||||||
|
"provider": "email",
|
||||||
|
"providers": ["email"]
|
||||||
|
},
|
||||||
|
"user_metadata": {},
|
||||||
|
"identities": null,
|
||||||
|
"created_at": "2021-12-15T12:43:56.730209-05:00",
|
||||||
|
"updated_at": "2021-12-15T12:43:56.730213-05:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7282cf42-344e-4474-bdf6-d48e4968a2e4",
|
||||||
|
"aud": "authenticated",
|
||||||
|
"role": "authenticated",
|
||||||
|
"email": "user+2@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"app_metadata": {
|
||||||
|
"provider": "email",
|
||||||
|
"providers": ["email"]
|
||||||
|
},
|
||||||
|
"user_metadata": {},
|
||||||
|
"identities": null,
|
||||||
|
"created_at": "2021-12-15T12:43:54.867676-05:00",
|
||||||
|
"updated_at": "2021-12-15T12:43:54.867679-05:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e78c512d-68e4-482b-901b-75003e89acae",
|
||||||
|
"aud": "authenticated",
|
||||||
|
"role": "authenticated",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"app_metadata": {
|
||||||
|
"provider": "email",
|
||||||
|
"providers": ["email"]
|
||||||
|
},
|
||||||
|
"user_metadata": {},
|
||||||
|
"identities": null,
|
||||||
|
"created_at": "2021-12-15T12:40:03.507551-05:00",
|
||||||
|
"updated_at": "2021-12-15T12:40:03.507554-05:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Database Migrations
|
||||||
|
|
||||||
|
If you need to run any new migrations:
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
make migrate_test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Currently, we don't use a separate test database, so the same database created when installing Auth to run locally is used.
|
||||||
|
|
||||||
|
The following commands should help in setting up a database and running the tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Runs the database in a docker container
|
||||||
|
$ docker-compose -f docker-compose-dev.yml up postgres
|
||||||
|
|
||||||
|
# Applies the migrations to the database (requires soda cli)
|
||||||
|
$ make migrate_test
|
||||||
|
|
||||||
|
# Executes the tests
|
||||||
|
$ make test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customizing the PostgreSQL Port
|
||||||
|
|
||||||
|
if you already run PostgreSQL and need to run your database on a different, custom port,
|
||||||
|
you will need to make several configuration changes to the following files:
|
||||||
|
|
||||||
|
In these examples, we change the port from 5432 to 7432.
|
||||||
|
|
||||||
|
> Note: This is not recommended, but if you do, please do not check in changes.
|
||||||
|
|
||||||
|
```
|
||||||
|
// file: docker-compose-dev.yml
|
||||||
|
ports:
|
||||||
|
- 7432:5432 \ 👈 set the first value to your external facing port
|
||||||
|
```
|
||||||
|
|
||||||
|
The port you customize here can them be used in the subsequent configuration:
|
||||||
|
|
||||||
|
```
|
||||||
|
// file: database.yaml
|
||||||
|
test:
|
||||||
|
dialect: "postgres"
|
||||||
|
database: "postgres"
|
||||||
|
host: {{ envOr "POSTGRES_HOST" "127.0.0.1" }}
|
||||||
|
port: {{ envOr "POSTGRES_PORT" "7432" }} 👈 set to your port
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
// file: test.env
|
||||||
|
DATABASE_URL="postgres://supabase_auth_admin:root@localhost:7432/postgres" 👈 set to your port
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
// file: migrate.sh
|
||||||
|
export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@localhost:7432/$DB_ENV"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helpful Docker Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
// file: docker-compose-dev.yml
|
||||||
|
container_name: auth_postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
# Command line into bash on the PostgreSQL container
|
||||||
|
docker exec -it auth_postgres bash
|
||||||
|
|
||||||
|
# Removes Container
|
||||||
|
docker container rm -f auth_postgres
|
||||||
|
|
||||||
|
# Removes volume
|
||||||
|
docker volume rm postgres_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Package Dependencies
|
||||||
|
|
||||||
|
- `make deps`
|
||||||
|
- `go mod tidy` if necessary
|
||||||
|
|
||||||
|
## Submitting Pull Requests
|
||||||
|
|
||||||
|
We actively welcome your pull requests.
|
||||||
|
|
||||||
|
- Fork the repo and create your branch from `master`.
|
||||||
|
- If you've added code that should be tested, add tests.
|
||||||
|
- If you've changed APIs, update the documentation.
|
||||||
|
- Ensure the test suite passes.
|
||||||
|
- Make sure your code lints.
|
||||||
|
|
||||||
|
### Checklist for Submitting Pull Requests
|
||||||
|
|
||||||
|
- Is there a corresponding issue created for it? If so, please include it in the PR description so we can track / refer to it.
|
||||||
|
- Does your PR follow the [semantic-release commit guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines)?
|
||||||
|
- If the PR is a `feat`, an [RFC](https://github.com/supabase/rfcs) or a detailed description of the design implementation is required. The former (RFC) is preferred before starting on the PR.
|
||||||
|
- Are the existing tests passing?
|
||||||
|
- Have you written some tests for your PR?
|
||||||
|
|
||||||
|
## Guidelines for Implementing Additional OAuth Providers
|
||||||
|
|
||||||
|
> ⚠️ We won't be accepting any additional oauth / sms provider contributions for now because we intend to support these through webhooks or a generic provider in the future.
|
||||||
|
|
||||||
|
Please ensure that an end-to-end test is done for the OAuth provider implemented.
|
||||||
|
|
||||||
|
An end-to-end test includes:
|
||||||
|
|
||||||
|
- Creating an application on the oauth provider site
|
||||||
|
- Generating your own client_id and secret
|
||||||
|
- Testing that `http://localhost:9999/authorize?provider=MY_COOL_NEW_PROVIDER` redirects you to the provider sign-in page
|
||||||
|
- The callback is handled properly
|
||||||
|
- Gotrue redirects to the `SITE_URL` or one of the URI's specified in the `URI_ALLOW_LIST` with the access_token, provider_token, expiry and refresh_token as query fragments
|
||||||
|
|
||||||
|
### Writing tests for the new OAuth provider implemented
|
||||||
|
|
||||||
|
Since implementing an additional OAuth provider consists of making api calls to an external api, we set up a mock server to attempt to mock the responses expected from the OAuth provider.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing to Auth, you agree that your contributions will be licensed
|
||||||
|
under its [MIT license](LICENSE).
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
FROM golang:1.22.3-alpine3.20 as build
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
ENV GOOS=linux
|
||||||
|
|
||||||
|
RUN apk add --no-cache make git
|
||||||
|
|
||||||
|
WORKDIR /go/src/github.com/supabase/auth
|
||||||
|
|
||||||
|
# Pulling dependencies
|
||||||
|
COPY ./Makefile ./go.* ./
|
||||||
|
RUN make deps
|
||||||
|
|
||||||
|
# Building stuff
|
||||||
|
COPY . /go/src/github.com/supabase/auth
|
||||||
|
|
||||||
|
# Make sure you change the RELEASE_VERSION value before publishing an image.
|
||||||
|
RUN RELEASE_VERSION=unspecified make build
|
||||||
|
|
||||||
|
# Always use alpine:3 so the latest version is used. This will keep CA certs more up to date.
|
||||||
|
FROM alpine:3
|
||||||
|
RUN adduser -D -u 1000 supabase
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=build /go/src/github.com/supabase/auth/auth /usr/local/bin/auth
|
||||||
|
COPY --from=build /go/src/github.com/supabase/auth/migrations /usr/local/etc/auth/migrations/
|
||||||
|
RUN ln -s /usr/local/bin/auth /usr/local/bin/gotrue
|
||||||
|
|
||||||
|
ENV GOTRUE_DB_MIGRATIONS_PATH /usr/local/etc/auth/migrations
|
||||||
|
|
||||||
|
USER supabase
|
||||||
|
CMD ["auth"]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM golang:1.22.3-alpine3.20
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
ENV GOOS=linux
|
||||||
|
|
||||||
|
RUN apk add --no-cache make git bash
|
||||||
|
|
||||||
|
WORKDIR /go/src/github.com/supabase/auth
|
||||||
|
|
||||||
|
# Pulling dependencies
|
||||||
|
COPY ./Makefile ./go.* ./
|
||||||
|
|
||||||
|
# Production dependencies
|
||||||
|
RUN make deps
|
||||||
|
|
||||||
|
# Development dependences
|
||||||
|
RUN go get github.com/githubnemo/CompileDaemon
|
||||||
|
RUN go install github.com/githubnemo/CompileDaemon
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
FROM postgres:15
|
||||||
|
WORKDIR /
|
||||||
|
RUN pwd
|
||||||
|
COPY init_postgres.sh /docker-entrypoint-initdb.d/init.sh
|
||||||
|
RUN chmod +x /docker-entrypoint-initdb.d/init.sh
|
||||||
|
EXPOSE 5432
|
||||||
|
|
||||||
|
CMD ["postgres"]
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Supabase <support@supabase.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
.PHONY: all build deps dev-deps image migrate test vet sec format unused
|
||||||
|
CHECK_FILES?=./...
|
||||||
|
|
||||||
|
FLAGS=-ldflags "-X github.com/supabase/auth/internal/utilities.Version=`git describe --tags`" -buildvcs=false
|
||||||
|
ifdef RELEASE_VERSION
|
||||||
|
FLAGS=-ldflags "-X github.com/supabase/auth/internal/utilities.Version=v$(RELEASE_VERSION)" -buildvcs=false
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifneq ($(shell docker compose version 2>/dev/null),)
|
||||||
|
DOCKER_COMPOSE=docker compose
|
||||||
|
else
|
||||||
|
DOCKER_COMPOSE=docker-compose
|
||||||
|
endif
|
||||||
|
|
||||||
|
DEV_DOCKER_COMPOSE:=docker-compose-dev.yml
|
||||||
|
|
||||||
|
help: ## Show this help.
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {sub("\\\\n",sprintf("\n%22c"," "), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
all: vet sec static build ## Run the tests and build the binary.
|
||||||
|
|
||||||
|
build: deps ## Build the binary.
|
||||||
|
CGO_ENABLED=0 go build $(FLAGS)
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(FLAGS) -o auth-arm64
|
||||||
|
|
||||||
|
dev-deps: ## Install developer dependencies
|
||||||
|
@go install github.com/gobuffalo/pop/soda@latest
|
||||||
|
@go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||||
|
@go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
|
@go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest
|
||||||
|
@go install github.com/nishanths/exhaustive/cmd/exhaustive@latest
|
||||||
|
|
||||||
|
deps: ## Install dependencies.
|
||||||
|
@go mod download
|
||||||
|
@go mod verify
|
||||||
|
|
||||||
|
migrate_dev: ## Run database migrations for development.
|
||||||
|
hack/migrate.sh postgres
|
||||||
|
|
||||||
|
migrate_test: ## Run database migrations for test.
|
||||||
|
hack/migrate.sh postgres
|
||||||
|
|
||||||
|
test: build ## Run tests.
|
||||||
|
go test $(CHECK_FILES) -coverprofile=coverage.out -coverpkg ./... -p 1 -race -v -count=1
|
||||||
|
./hack/coverage.sh
|
||||||
|
|
||||||
|
vet: # Vet the code
|
||||||
|
go vet $(CHECK_FILES)
|
||||||
|
|
||||||
|
sec: dev-deps # Check for security vulnerabilities
|
||||||
|
gosec -quiet -exclude-generated $(CHECK_FILES)
|
||||||
|
gosec -quiet -tests -exclude-generated -exclude=G104 $(CHECK_FILES)
|
||||||
|
|
||||||
|
unused: dev-deps # Look for unused code
|
||||||
|
@echo "Unused code:"
|
||||||
|
staticcheck -checks U1000 $(CHECK_FILES)
|
||||||
|
|
||||||
|
@echo
|
||||||
|
|
||||||
|
@echo "Code used only in _test.go (do move it in those files):"
|
||||||
|
staticcheck -checks U1000 -tests=false $(CHECK_FILES)
|
||||||
|
|
||||||
|
static: dev-deps
|
||||||
|
staticcheck ./...
|
||||||
|
exhaustive ./...
|
||||||
|
|
||||||
|
generate: dev-deps
|
||||||
|
go generate ./...
|
||||||
|
|
||||||
|
dev: ## Run the development containers
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) up
|
||||||
|
|
||||||
|
down: ## Shutdown the development containers
|
||||||
|
# Start postgres first and apply migrations
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) down
|
||||||
|
|
||||||
|
docker-test: ## Run the tests using the development containers
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) up -d postgres
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) run auth sh -c "make migrate_test"
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) run auth sh -c "make test"
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) down -v
|
||||||
|
|
||||||
|
docker-build: ## Force a full rebuild of the development containers
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) build --no-cache
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) up -d postgres
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) run auth sh -c "make migrate_dev"
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) down
|
||||||
|
|
||||||
|
docker-clean: ## Remove the development containers and volumes
|
||||||
|
${DOCKER_COMPOSE} -f $(DEV_DOCKER_COMPOSE) rm -fsv
|
||||||
|
|
||||||
|
format:
|
||||||
|
gofmt -s -w .
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Auth is a project maintained by [Supabase](https://supabase.com). Below is
|
||||||
|
our security policy.
|
||||||
|
|
||||||
|
Contact: security@supabase.io
|
||||||
|
Canonical: https://supabase.com/.well-known/security.txt
|
||||||
|
|
||||||
|
At Supabase, we consider the security of our systems a top priority. But no
|
||||||
|
matter how much effort we put into system security, there can still be
|
||||||
|
vulnerabilities present.
|
||||||
|
|
||||||
|
If you discover a vulnerability, we would like to know about it so we can take
|
||||||
|
steps to address it as quickly as possible. We would like to ask you to help us
|
||||||
|
better protect our clients and our systems.
|
||||||
|
|
||||||
|
Out of scope vulnerabilities:
|
||||||
|
|
||||||
|
- Clickjacking on pages with no sensitive actions.
|
||||||
|
- Unauthenticated/logout/login CSRF.
|
||||||
|
- Attacks requiring MITM or physical access to a user's device.
|
||||||
|
- Any activity that could lead to the disruption of our service (DoS).
|
||||||
|
- Content spoofing and text injection issues without showing an attack
|
||||||
|
vector/without being able to modify HTML/CSS.
|
||||||
|
- Email spoofing
|
||||||
|
- Missing DNSSEC, CAA, CSP headers
|
||||||
|
- Lack of Secure or HTTP only flag on non-sensitive cookies
|
||||||
|
- Deadlinks
|
||||||
|
|
||||||
|
Please do the following:
|
||||||
|
|
||||||
|
- E-mail your findings to security@supabase.io.
|
||||||
|
- Do not run automated scanners on our infrastructure or dashboard. If you wish
|
||||||
|
to do this, contact us and we will set up a sandbox for you.
|
||||||
|
- Do not take advantage of the vulnerability or problem you have discovered,
|
||||||
|
for example by downloading more data than necessary to demonstrate the
|
||||||
|
vulnerability or deleting or modifying other people's data,
|
||||||
|
- Do not reveal the problem to others until it has been resolved,
|
||||||
|
- Do not use attacks on physical security, social engineering, distributed
|
||||||
|
denial of service, spam or applications of third parties, and
|
||||||
|
- Do provide sufficient information to reproduce the problem, so we will be
|
||||||
|
able to resolve it as quickly as possible. Usually, the IP address or the URL
|
||||||
|
of the affected system and a description of the vulnerability will be
|
||||||
|
sufficient, but complex vulnerabilities may require further explanation.
|
||||||
|
|
||||||
|
What we promise:
|
||||||
|
|
||||||
|
- We will respond to your report within 3 business days with our evaluation of
|
||||||
|
the report and an expected resolution date,
|
||||||
|
- If you have followed the instructions above, we will not take any legal
|
||||||
|
action against you in regard to the report,
|
||||||
|
- We will handle your report with strict confidentiality, and not pass on your
|
||||||
|
personal details to third parties without your permission,
|
||||||
|
- We will keep you informed of the progress towards resolving the problem,
|
||||||
|
- In the public information concerning the problem reported, we will give your
|
||||||
|
name as the discoverer of the problem (unless you desire otherwise), and
|
||||||
|
|
||||||
|
We strive to resolve all problems as quickly as possible, and we would like to
|
||||||
|
play an active role in the ultimate publication on the problem after it is
|
||||||
|
resolved.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "Gotrue",
|
||||||
|
"description": "",
|
||||||
|
"website": "https://www.gotrueapi.org",
|
||||||
|
"repository": "https://github.com/supabase/gotrue",
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL": {},
|
||||||
|
"GOTRUE_DB_DRIVER": {
|
||||||
|
"value": "postgres"
|
||||||
|
},
|
||||||
|
"GOTRUE_DB_AUTOMIGRATE": {
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"GOTRUE_DB_NAMESPACE": {
|
||||||
|
"value": "auth"
|
||||||
|
},
|
||||||
|
"GOTRUE_JWT_SECRET": {
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"GOTRUE_SMTP_ADMIN_EMAIL": {},
|
||||||
|
"GOTRUE_SMTP_HOST": {},
|
||||||
|
"GOTRUE_SMTP_PASS": {},
|
||||||
|
"GOTRUE_SMTP_PORT": {},
|
||||||
|
"GOTRUE_MAILER_SITE_URL": {},
|
||||||
|
"GOTRUE_MAILER_SUBJECTS_CONFIRMATION": {},
|
||||||
|
"GOTRUE_MAILER_SUBJECTS_RECOVERY": {},
|
||||||
|
"GOTRUE_MAILER_SUBJECTS_MAGIC_LINK": {},
|
||||||
|
"GOTRUE_MAILER_TEMPLATES_CONFIRMATION": {},
|
||||||
|
"GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE": {},
|
||||||
|
"GOTRUE_MAILER_TEMPLATES_RECOVERY": {},
|
||||||
|
"GOTRUE_MAILER_TEMPLATES_MAGIC_LINK": {},
|
||||||
|
"GOTRUE_MAILER_USER": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,3 @@
|
||||||
|
package admin
|
||||||
|
|
||||||
|
//go:generate oapi-codegen -config ./oapi-codegen.yaml ../../openapi.yaml
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package: admin
|
||||||
|
generate:
|
||||||
|
- client
|
||||||
|
- types
|
||||||
|
include-tags:
|
||||||
|
- admin
|
||||||
|
output: client.go
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
var autoconfirm, isAdmin bool
|
||||||
|
var audience string
|
||||||
|
|
||||||
|
func getAudience(c *conf.GlobalConfiguration) string {
|
||||||
|
if audience == "" {
|
||||||
|
return c.JWT.Aud
|
||||||
|
}
|
||||||
|
|
||||||
|
return audience
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminCmd() *cobra.Command {
|
||||||
|
var adminCmd = &cobra.Command{
|
||||||
|
Use: "admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
adminCmd.AddCommand(&adminCreateUserCmd, &adminDeleteUserCmd)
|
||||||
|
adminCmd.PersistentFlags().StringVarP(&audience, "aud", "a", "", "Set the new user's audience")
|
||||||
|
|
||||||
|
adminCreateUserCmd.Flags().BoolVar(&autoconfirm, "confirm", false, "Automatically confirm user without sending an email")
|
||||||
|
adminCreateUserCmd.Flags().BoolVar(&isAdmin, "admin", false, "Create user with admin privileges")
|
||||||
|
|
||||||
|
return adminCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminCreateUserCmd = cobra.Command{
|
||||||
|
Use: "createuser",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
logrus.Fatal("Not enough arguments to createuser command. Expected at least email and password values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
execWithConfigAndArgs(cmd, adminCreateUser, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminDeleteUserCmd = cobra.Command{
|
||||||
|
Use: "deleteuser",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
logrus.Fatal("Not enough arguments to deleteuser command. Expected at least ID or email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
execWithConfigAndArgs(cmd, adminDeleteUser, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminCreateUser(config *conf.GlobalConfiguration, args []string) {
|
||||||
|
db, err := storage.Dial(config)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("Error opening database: %+v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
aud := getAudience(config)
|
||||||
|
if user, err := models.IsDuplicatedEmail(db, args[0], aud, nil); user != nil {
|
||||||
|
logrus.Fatalf("Error creating new user: user already exists")
|
||||||
|
} else if err != nil {
|
||||||
|
logrus.Fatalf("Error checking user email: %+v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := models.NewUser("", args[0], args[1], aud, nil)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("Error creating new user: %+v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
var terr error
|
||||||
|
if terr = tx.Create(user); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 2 {
|
||||||
|
if terr = user.SetRole(tx, args[2]); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
} else if isAdmin {
|
||||||
|
if terr = user.SetRole(tx, config.JWT.AdminGroupName); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Mailer.Autoconfirm || autoconfirm {
|
||||||
|
if terr = user.Confirm(tx); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("Unable to create user (%s): %+v", args[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Infof("Created user: %s", args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminDeleteUser(config *conf.GlobalConfiguration, args []string) {
|
||||||
|
db, err := storage.Dial(config)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("Error opening database: %+v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
user, err := models.FindUserByEmailAndAudience(db, args[0], getAudience(config))
|
||||||
|
if err != nil {
|
||||||
|
userID := uuid.Must(uuid.FromString(args[0]))
|
||||||
|
user, err = models.FindUserByID(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("Error finding user (%s): %+v", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = db.Destroy(user); err != nil {
|
||||||
|
logrus.Fatalf("Error removing user (%s): %+v", args[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Infof("Removed user: %s", args[0])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gobuffalo/pop/v6"
|
||||||
|
"github.com/gobuffalo/pop/v6/logging"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var EmbeddedMigrations embed.FS
|
||||||
|
|
||||||
|
var migrateCmd = cobra.Command{
|
||||||
|
Use: "migrate",
|
||||||
|
Long: "Migrate database strucutures. This will create new tables and add missing columns and indexes.",
|
||||||
|
Run: migrate,
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(cmd *cobra.Command, args []string) {
|
||||||
|
globalConfig := loadGlobalConfig(cmd.Context())
|
||||||
|
|
||||||
|
if globalConfig.DB.Driver == "" && globalConfig.DB.URL != "" {
|
||||||
|
u, err := url.Parse(globalConfig.DB.URL)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("%+v", errors.Wrap(err, "parsing db connection url"))
|
||||||
|
}
|
||||||
|
globalConfig.DB.Driver = u.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
log := logrus.StandardLogger()
|
||||||
|
|
||||||
|
pop.Debug = false
|
||||||
|
if globalConfig.Logging.Level != "" {
|
||||||
|
level, err := logrus.ParseLevel(globalConfig.Logging.Level)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse log level: %+v", err)
|
||||||
|
}
|
||||||
|
log.SetLevel(level)
|
||||||
|
if level == logrus.DebugLevel {
|
||||||
|
// Set to true to display query info
|
||||||
|
pop.Debug = true
|
||||||
|
}
|
||||||
|
if level != logrus.DebugLevel {
|
||||||
|
var noopLogger = func(lvl logging.Level, s string, args ...interface{}) {
|
||||||
|
}
|
||||||
|
// Hide pop migration logging
|
||||||
|
pop.SetLogger(noopLogger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, _ := url.Parse(globalConfig.DB.URL)
|
||||||
|
processedUrl := globalConfig.DB.URL
|
||||||
|
if len(u.Query()) != 0 {
|
||||||
|
processedUrl = fmt.Sprintf("%s&application_name=gotrue_migrations", processedUrl)
|
||||||
|
} else {
|
||||||
|
processedUrl = fmt.Sprintf("%s?application_name=gotrue_migrations", processedUrl)
|
||||||
|
}
|
||||||
|
deets := &pop.ConnectionDetails{
|
||||||
|
Dialect: globalConfig.DB.Driver,
|
||||||
|
URL: processedUrl,
|
||||||
|
}
|
||||||
|
deets.Options = map[string]string{
|
||||||
|
"migration_table_name": "schema_migrations",
|
||||||
|
"Namespace": globalConfig.DB.Namespace,
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := pop.NewConnection(deets)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%+v", errors.Wrap(err, "opening db connection"))
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Open(); err != nil {
|
||||||
|
log.Fatalf("%+v", errors.Wrap(err, "checking database connection"))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Reading migrations from executable")
|
||||||
|
box, err := pop.NewMigrationBox(EmbeddedMigrations, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%+v", errors.Wrap(err, "creating db migrator"))
|
||||||
|
}
|
||||||
|
|
||||||
|
mig := box.Migrator
|
||||||
|
|
||||||
|
log.Debugf("before status")
|
||||||
|
|
||||||
|
if log.Level == logrus.DebugLevel {
|
||||||
|
err = mig.Status(os.Stdout)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%+v", errors.Wrap(err, "migration status"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// turn off schema dump
|
||||||
|
mig.SchemaPath = ""
|
||||||
|
|
||||||
|
err = mig.Up()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", errors.Wrap(err, "running db migrations"))
|
||||||
|
} else {
|
||||||
|
log.Infof("GoTrue migrations applied successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("after status")
|
||||||
|
|
||||||
|
if log.Level == logrus.DebugLevel {
|
||||||
|
err = mig.Status(os.Stdout)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%+v", errors.Wrap(err, "migration status"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configFile = ""
|
||||||
|
watchDir = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = cobra.Command{
|
||||||
|
Use: "gotrue",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
migrate(cmd, args)
|
||||||
|
serve(cmd.Context())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootCommand will setup and return the root command
|
||||||
|
func RootCommand() *cobra.Command {
|
||||||
|
rootCmd.AddCommand(&serveCmd, &migrateCmd, &versionCmd, adminCmd())
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "base configuration file to load")
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&watchDir, "config-dir", "d", "", "directory containing a sorted list of config files to watch for changes")
|
||||||
|
return &rootCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGlobalConfig(ctx context.Context) *conf.GlobalConfiguration {
|
||||||
|
if ctx == nil {
|
||||||
|
panic("context must not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := conf.LoadGlobal(configFile)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("Failed to load configuration: %+v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := observability.ConfigureLogging(&config.Logging); err != nil {
|
||||||
|
logrus.WithError(err).Error("unable to configure logging")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := observability.ConfigureTracing(ctx, &config.Tracing); err != nil {
|
||||||
|
logrus.WithError(err).Error("unable to configure tracing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := observability.ConfigureMetrics(ctx, &config.Metrics); err != nil {
|
||||||
|
logrus.WithError(err).Error("unable to configure metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := observability.ConfigureProfiler(ctx, &config.Profiler); err != nil {
|
||||||
|
logrus.WithError(err).Error("unable to configure profiler")
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func execWithConfigAndArgs(cmd *cobra.Command, fn func(config *conf.GlobalConfiguration, args []string), args []string) {
|
||||||
|
fn(loadGlobalConfig(cmd.Context()), args)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/supabase/auth/internal/api"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/reloader"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
var serveCmd = cobra.Command{
|
||||||
|
Use: "serve",
|
||||||
|
Long: "Start API server",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
serve(cmd.Context())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(ctx context.Context) {
|
||||||
|
if err := conf.LoadFile(configFile); err != nil {
|
||||||
|
logrus.WithError(err).Fatal("unable to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conf.LoadDirectory(watchDir); err != nil {
|
||||||
|
logrus.WithError(err).Fatal("unable to load config from watch dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := conf.LoadGlobalFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Fatal("unable to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := storage.Dial(config)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Fatalf("error opening database: %+v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(config.API.Host, config.API.Port)
|
||||||
|
logrus.Infof("GoTrue API started on: %s", addr)
|
||||||
|
|
||||||
|
opts := []api.Option{
|
||||||
|
api.NewLimiterOptions(config),
|
||||||
|
}
|
||||||
|
a := api.NewAPIWithVersion(config, db, utilities.Version, opts...)
|
||||||
|
ah := reloader.NewAtomicHandler(a)
|
||||||
|
|
||||||
|
baseCtx, baseCancel := context.WithCancel(context.Background())
|
||||||
|
defer baseCancel()
|
||||||
|
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: ah,
|
||||||
|
ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
|
||||||
|
BaseContext: func(net.Listener) context.Context {
|
||||||
|
return baseCtx
|
||||||
|
},
|
||||||
|
}
|
||||||
|
log := logrus.WithField("component", "api")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
defer wg.Wait() // Do not return to caller until this goroutine is done.
|
||||||
|
|
||||||
|
if watchDir != "" {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
fn := func(latestCfg *conf.GlobalConfiguration) {
|
||||||
|
log.Info("reloading api with new configuration")
|
||||||
|
latestAPI := api.NewAPIWithVersion(
|
||||||
|
latestCfg, db, utilities.Version, opts...)
|
||||||
|
ah.Store(latestAPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
rl := reloader.NewReloader(watchDir)
|
||||||
|
if err := rl.Watch(ctx, fn); err != nil {
|
||||||
|
log.WithError(err).Error("watcher is exiting")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
defer baseCancel() // close baseContext
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := httpSrv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.WithError(err).Error("shutdown failed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
log.WithError(err).Fatal("http server listen failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
var versionCmd = cobra.Command{
|
||||||
|
Run: showVersion,
|
||||||
|
Use: "version",
|
||||||
|
}
|
||||||
|
|
||||||
|
func showVersion(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println(utilities.Version)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
auth:
|
||||||
|
container_name: auth
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
build:
|
||||||
|
context: ./
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
ports:
|
||||||
|
- '9999:9999'
|
||||||
|
- '9100:9100'
|
||||||
|
environment:
|
||||||
|
- GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/supabase/auth/migrations
|
||||||
|
volumes:
|
||||||
|
- ./:/go/src/github.com/supabase/auth
|
||||||
|
command: CompileDaemon --build="make build" --directory=/go/src/github.com/supabase/auth --recursive=true -pattern="(.+\.go|.+\.env)" -exclude=auth -exclude=auth-arm64 -exclude=.env --command="/go/src/github.com/supabase/auth/auth -c=.env.docker"
|
||||||
|
postgres:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.postgres.dev
|
||||||
|
container_name: auth_postgres
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=root
|
||||||
|
- POSTGRES_DB=postgres
|
||||||
|
# sets the schema name, this should match the `NAMESPACE` env var set in your .env file
|
||||||
|
- DB_NAMESPACE=auth
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/supabase/auth/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swagger:route GET /admin/users admin admin-list-users
|
||||||
|
// List all users.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// responses:
|
||||||
|
// 200: adminListUserResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The list of users.
|
||||||
|
// swagger:response adminListUserResponse
|
||||||
|
type adminListUserResponseWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.AdminListUsersResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:route POST /admin/users admin admin-create-user
|
||||||
|
// Returns the created user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// responses:
|
||||||
|
// 200: userResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The user to be created.
|
||||||
|
// swagger:parameters admin-create-user
|
||||||
|
type adminUserParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.AdminUserParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:route GET /admin/user/{user_id} admin admin-get-user
|
||||||
|
// Get a user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// parameters:
|
||||||
|
// + name: user_id
|
||||||
|
// in: path
|
||||||
|
// description: The user's id
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// 200: userResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The user specified.
|
||||||
|
// swagger:response userResponse
|
||||||
|
|
||||||
|
// swagger:route PUT /admin/user/{user_id} admin admin-update-user
|
||||||
|
// Update a user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// parameters:
|
||||||
|
// + name: user_id
|
||||||
|
// in: path
|
||||||
|
// description: The user's id
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// 200: userResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The updated user.
|
||||||
|
// swagger:response userResponse
|
||||||
|
|
||||||
|
// swagger:route DELETE /admin/user/{user_id} admin admin-delete-user
|
||||||
|
// Deletes a user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// parameters:
|
||||||
|
// + name: user_id
|
||||||
|
// in: path
|
||||||
|
// description: The user's id
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// 200: deleteUserResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The updated user.
|
||||||
|
// swagger:response deleteUserResponse
|
||||||
|
type deleteUserResponseWrapper struct{}
|
||||||
|
|
||||||
|
// swagger:route POST /admin/generate_link admin admin-generate-link
|
||||||
|
// Generates an email action link.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// responses:
|
||||||
|
// 200: generateLinkResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// swagger:parameters admin-generate-link
|
||||||
|
type generateLinkParams struct {
|
||||||
|
// in:body
|
||||||
|
Body api.GenerateLinkParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// The response object for generate link.
|
||||||
|
// swagger:response generateLinkResponse
|
||||||
|
type generateLinkResponseWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.GenerateLinkResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Package classification gotrue
|
||||||
|
//
|
||||||
|
// Documentation of the gotrue API.
|
||||||
|
//
|
||||||
|
// Schemes: http, https
|
||||||
|
// BasePath: /
|
||||||
|
// Version: 1.0.0
|
||||||
|
// Host: localhost:9999
|
||||||
|
//
|
||||||
|
// SecurityDefinitions:
|
||||||
|
// bearer:
|
||||||
|
// type: apiKey
|
||||||
|
// name: Authentication
|
||||||
|
// in: header
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// swagger:meta
|
||||||
|
package docs
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
// This endpoint requires a bearer token.
|
||||||
|
// swagger:response unauthorizedError
|
||||||
|
type unauthorizedError struct{}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/supabase/auth/internal/api"
|
||||||
|
|
||||||
|
// swagger:route GET /health health health
|
||||||
|
// The healthcheck endpoint for gotrue. Returns the current gotrue version.
|
||||||
|
// responses:
|
||||||
|
// 200: healthCheckResponse
|
||||||
|
|
||||||
|
// swagger:response healthCheckResponse
|
||||||
|
type healthCheckResponseWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.HealthCheckResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/supabase/auth/internal/api"
|
||||||
|
|
||||||
|
// swagger:route POST /invite invite invite
|
||||||
|
// Sends an invite link to the user.
|
||||||
|
// responses:
|
||||||
|
// 200: inviteResponse
|
||||||
|
|
||||||
|
// swagger:parameters invite
|
||||||
|
type inviteParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.InviteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:response inviteResponse
|
||||||
|
type inviteResponseWrapper struct{}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
// swagger:route POST /logout logout logout
|
||||||
|
// Logs out the user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// responses:
|
||||||
|
// 204: logoutResponse
|
||||||
|
|
||||||
|
// swagger:response logoutResponse
|
||||||
|
type logoutResponseWrapper struct{}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
// swagger:route GET /authorize oauth authorize
|
||||||
|
// Redirects the user to the 3rd-party OAuth provider to start the OAuth1.0 or OAuth2.0 authentication process.
|
||||||
|
// parameters:
|
||||||
|
// + name: redirect_to
|
||||||
|
// in: query
|
||||||
|
// description: The redirect url to return the user to after the `/callback` endpoint has completed.
|
||||||
|
// required: false
|
||||||
|
// responses:
|
||||||
|
// 302: authorizeResponse
|
||||||
|
|
||||||
|
// Redirects user to the 3rd-party OAuth provider
|
||||||
|
// swagger:response authorizeResponse
|
||||||
|
type authorizeResponseWrapper struct{}
|
||||||
|
|
||||||
|
// swagger:route GET /callback oauth callback
|
||||||
|
// Receives the redirect from an external provider during the OAuth authentication process. Starts the process of creating an access and refresh token.
|
||||||
|
// responses:
|
||||||
|
// 302: callbackResponse
|
||||||
|
|
||||||
|
// Redirects user to the redirect url specified in `/authorize`. If no `redirect_url` is provided, the user will be redirected to the `SITE_URL`.
|
||||||
|
// swagger:response callbackResponse
|
||||||
|
type callbackResponseWrapper struct{}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/supabase/auth/internal/api"
|
||||||
|
|
||||||
|
// swagger:route POST /otp otp otp
|
||||||
|
// Passwordless sign-in method for email or phone.
|
||||||
|
// responses:
|
||||||
|
// 200: otpResponse
|
||||||
|
|
||||||
|
// swagger:parameters otp
|
||||||
|
type otpParamsWrapper struct {
|
||||||
|
// Only an email or phone should be provided.
|
||||||
|
// in:body
|
||||||
|
Body api.OtpParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:response otpResponse
|
||||||
|
type otpResponseWrapper struct{}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/supabase/auth/internal/api"
|
||||||
|
|
||||||
|
// swagger:route POST /recover recovery recovery
|
||||||
|
// Sends a password recovery email link to the user's email.
|
||||||
|
// responses:
|
||||||
|
// 200: recoveryResponse
|
||||||
|
|
||||||
|
// swagger:parameters recovery
|
||||||
|
type recoveryParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.RecoverParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:response recoveryResponse
|
||||||
|
type recoveryResponseWrapper struct{}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/supabase/auth/internal/api"
|
||||||
|
|
||||||
|
// swagger:route GET /settings settings settings
|
||||||
|
// Returns the configuration settings for the gotrue server.
|
||||||
|
// responses:
|
||||||
|
// 200: settingsResponse
|
||||||
|
|
||||||
|
// swagger:response settingsResponse
|
||||||
|
type settingsResponseWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.Settings
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/supabase/auth/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swagger:route POST /signup signup signup
|
||||||
|
// Password-based signup with either email or phone.
|
||||||
|
// responses:
|
||||||
|
// 200: userResponse
|
||||||
|
|
||||||
|
// swagger:parameters signup
|
||||||
|
type signupParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.SignupParams
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/supabase/auth/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swagger:route POST /token?grant_type=password token token-password
|
||||||
|
// Signs in a user with a password.
|
||||||
|
// responses:
|
||||||
|
// 200: tokenResponse
|
||||||
|
|
||||||
|
// swagger:parameters token-password
|
||||||
|
type tokenPasswordGrantParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.PasswordGrantParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:route POST /token?grant_type=refresh_token token token-refresh
|
||||||
|
// Refreshes a user's refresh token.
|
||||||
|
// responses:
|
||||||
|
// 200: tokenResponse
|
||||||
|
|
||||||
|
// swagger:parameters token-refresh
|
||||||
|
type tokenRefreshTokenGrantParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.RefreshTokenGrantParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:response tokenResponse
|
||||||
|
type tokenResponseWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.AccessTokenResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/supabase/auth/internal/api"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swagger:route GET /user user user-get
|
||||||
|
// Get information for the logged-in user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// responses:
|
||||||
|
// 200: userResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The current user.
|
||||||
|
// swagger:response userResponse
|
||||||
|
type userResponseWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body models.User
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:route PUT /user user user-put
|
||||||
|
// Returns the updated user.
|
||||||
|
// security:
|
||||||
|
// - bearer:
|
||||||
|
// responses:
|
||||||
|
// 200: userResponse
|
||||||
|
// 401: unauthorizedError
|
||||||
|
|
||||||
|
// The current user.
|
||||||
|
// swagger:parameters user-put
|
||||||
|
type userUpdateParams struct {
|
||||||
|
// in:body
|
||||||
|
Body api.UserUpdateParams
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
//lint:file-ignore U1000 ignore go-swagger template
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/supabase/auth/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swagger:route GET /verify verify verify-get
|
||||||
|
// Verifies a sign up.
|
||||||
|
|
||||||
|
// swagger:parameters verify-get
|
||||||
|
type verifyGetParamsWrapper struct {
|
||||||
|
// in:query
|
||||||
|
api.VerifyParams
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:route POST /verify verify verify-post
|
||||||
|
// Verifies a sign up.
|
||||||
|
|
||||||
|
// swagger:parameters verify-post
|
||||||
|
type verifyPostParamsWrapper struct {
|
||||||
|
// in:body
|
||||||
|
Body api.VerifyParams
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
GOTRUE_SITE_URL="http://localhost:3000"
|
||||||
|
GOTRUE_JWT_SECRET=""
|
||||||
|
GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/supabase/auth/migrations
|
||||||
|
GOTRUE_DB_DRIVER=postgres
|
||||||
|
DATABASE_URL=postgres://supabase_auth_admin:root@postgres:5432/postgres
|
||||||
|
GOTRUE_API_HOST=0.0.0.0
|
||||||
|
API_EXTERNAL_URL="http://localhost:9999"
|
||||||
|
PORT=9999
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
# General Config
|
||||||
|
# NOTE: The service_role key is required as an authorization header for /admin endpoints
|
||||||
|
|
||||||
|
GOTRUE_JWT_SECRET="CHANGE-THIS! VERY IMPORTANT!"
|
||||||
|
GOTRUE_JWT_EXP="3600"
|
||||||
|
GOTRUE_JWT_AUD="authenticated"
|
||||||
|
GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated"
|
||||||
|
GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role"
|
||||||
|
|
||||||
|
# Database & API connection details
|
||||||
|
GOTRUE_DB_DRIVER="postgres"
|
||||||
|
DB_NAMESPACE="auth"
|
||||||
|
DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres"
|
||||||
|
API_EXTERNAL_URL="http://localhost:9999"
|
||||||
|
GOTRUE_API_HOST="localhost"
|
||||||
|
PORT="9999"
|
||||||
|
|
||||||
|
# SMTP config (generate credentials for signup to work)
|
||||||
|
GOTRUE_SMTP_HOST=""
|
||||||
|
GOTRUE_SMTP_PORT=""
|
||||||
|
GOTRUE_SMTP_USER=""
|
||||||
|
GOTRUE_SMTP_MAX_FREQUENCY="5s"
|
||||||
|
GOTRUE_SMTP_PASS=""
|
||||||
|
GOTRUE_SMTP_ADMIN_EMAIL=""
|
||||||
|
GOTRUE_SMTP_SENDER_NAME=""
|
||||||
|
|
||||||
|
# Mailer config
|
||||||
|
GOTRUE_MAILER_AUTOCONFIRM="true"
|
||||||
|
GOTRUE_MAILER_URLPATHS_CONFIRMATION="/verify"
|
||||||
|
GOTRUE_MAILER_URLPATHS_INVITE="/verify"
|
||||||
|
GOTRUE_MAILER_URLPATHS_RECOVERY="/verify"
|
||||||
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE="/verify"
|
||||||
|
GOTRUE_MAILER_SUBJECTS_CONFIRMATION="Confirm Your Email"
|
||||||
|
GOTRUE_MAILER_SUBJECTS_RECOVERY="Reset Your Password"
|
||||||
|
GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link"
|
||||||
|
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change"
|
||||||
|
GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited"
|
||||||
|
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
|
||||||
|
|
||||||
|
# Custom mailer template config
|
||||||
|
GOTRUE_MAILER_TEMPLATES_INVITE=""
|
||||||
|
GOTRUE_MAILER_TEMPLATES_CONFIRMATION=""
|
||||||
|
GOTRUE_MAILER_TEMPLATES_RECOVERY=""
|
||||||
|
GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=""
|
||||||
|
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
|
||||||
|
|
||||||
|
# Signup config
|
||||||
|
GOTRUE_DISABLE_SIGNUP="false"
|
||||||
|
GOTRUE_SITE_URL="http://localhost:3000"
|
||||||
|
GOTRUE_EXTERNAL_EMAIL_ENABLED="true"
|
||||||
|
GOTRUE_EXTERNAL_PHONE_ENABLED="true"
|
||||||
|
GOTRUE_EXTERNAL_IOS_BUNDLE_ID="com.supabase.auth"
|
||||||
|
|
||||||
|
# Whitelist redirect to URLs here, a comma separated list of URIs (e.g. "https://foo.example.com,https://*.foo.example.com,https://bar.example.com")
|
||||||
|
GOTRUE_URI_ALLOW_LIST="http://localhost:3000"
|
||||||
|
|
||||||
|
# Apple OAuth config
|
||||||
|
GOTRUE_EXTERNAL_APPLE_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_APPLE_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_APPLE_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Azure OAuth config
|
||||||
|
GOTRUE_EXTERNAL_AZURE_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_AZURE_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_AZURE_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI="https://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Bitbucket OAuth config
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Discord OAuth config
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI="https://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Facebook OAuth config
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI="https://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Figma OAuth config
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_REDIRECT_URI="https://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Gitlab OAuth config
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Google OAuth config
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Github OAuth config
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Kakao OAuth config
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Notion OAuth config
|
||||||
|
GOTRUE_EXTERNAL_NOTION_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_NOTION_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_NOTION_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_NOTION_REDIRECT_URI="https://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Twitter OAuth1 config
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Twitch OAuth config
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Spotify OAuth config
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Keycloak OAuth config
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_URL="https://keycloak.example.com/auth/realms/myrealm"
|
||||||
|
|
||||||
|
# Linkedin OAuth config
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true"
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_SECRET=""
|
||||||
|
|
||||||
|
# Slack OAuth config
|
||||||
|
GOTRUE_EXTERNAL_SLACK_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_SLACK_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_SLACK_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# WorkOS OAuth config
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_ENABLED="true"
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Zoom OAuth config
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_ENABLED="false"
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=""
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_SECRET=""
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback"
|
||||||
|
|
||||||
|
# Anonymous auth config
|
||||||
|
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false"
|
||||||
|
|
||||||
|
# PKCE Config
|
||||||
|
GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION="300s"
|
||||||
|
|
||||||
|
# Phone provider config
|
||||||
|
GOTRUE_SMS_AUTOCONFIRM="false"
|
||||||
|
GOTRUE_SMS_MAX_FREQUENCY="5s"
|
||||||
|
GOTRUE_SMS_OTP_EXP="6000"
|
||||||
|
GOTRUE_SMS_OTP_LENGTH="6"
|
||||||
|
GOTRUE_SMS_PROVIDER="twilio"
|
||||||
|
GOTRUE_SMS_TWILIO_ACCOUNT_SID=""
|
||||||
|
GOTRUE_SMS_TWILIO_AUTH_TOKEN=""
|
||||||
|
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID=""
|
||||||
|
GOTRUE_SMS_TEMPLATE="This is from supabase. Your code is {{ .Code }} ."
|
||||||
|
GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY=""
|
||||||
|
GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR=""
|
||||||
|
GOTRUE_SMS_TEXTLOCAL_API_KEY=""
|
||||||
|
GOTRUE_SMS_TEXTLOCAL_SENDER=""
|
||||||
|
GOTRUE_SMS_VONAGE_API_KEY=""
|
||||||
|
GOTRUE_SMS_VONAGE_API_SECRET=""
|
||||||
|
GOTRUE_SMS_VONAGE_FROM=""
|
||||||
|
|
||||||
|
# Captcha config
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"
|
||||||
|
GOTRUE_SESSION_KEY=""
|
||||||
|
|
||||||
|
# SAML config
|
||||||
|
GOTRUE_EXTERNAL_SAML_ENABLED="true"
|
||||||
|
GOTRUE_EXTERNAL_SAML_METADATA_URL=""
|
||||||
|
GOTRUE_EXTERNAL_SAML_API_BASE="http://localhost:9999"
|
||||||
|
GOTRUE_EXTERNAL_SAML_NAME="auth0"
|
||||||
|
GOTRUE_EXTERNAL_SAML_SIGNING_CERT=""
|
||||||
|
GOTRUE_EXTERNAL_SAML_SIGNING_KEY=""
|
||||||
|
|
||||||
|
# Additional Security config
|
||||||
|
GOTRUE_LOG_LEVEL="debug"
|
||||||
|
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="false"
|
||||||
|
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="0"
|
||||||
|
GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false"
|
||||||
|
GOTRUE_OPERATOR_TOKEN="unused-operator-token"
|
||||||
|
GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For"
|
||||||
|
GOTRUE_RATE_LIMIT_EMAIL_SENT="100"
|
||||||
|
|
||||||
|
GOTRUE_MAX_VERIFIED_FACTORS=10
|
||||||
|
|
||||||
|
# Auth Hook Configuration
|
||||||
|
GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED=false
|
||||||
|
GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI=""
|
||||||
|
# Only for HTTPS Hooks
|
||||||
|
GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRET=""
|
||||||
|
|
||||||
|
GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_ENABLED=false
|
||||||
|
GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_URI=""
|
||||||
|
# Only for HTTPS Hooks
|
||||||
|
GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_SECRET=""
|
||||||
|
|
||||||
|
|
||||||
|
# Test OTP Config
|
||||||
|
GOTRUE_SMS_TEST_OTP="<phone-1>:<otp-1>, <phone-2>:<otp-2>..."
|
||||||
|
GOTRUE_SMS_TEST_OTP_VALID_UNTIL="<ISO date time>" # (e.g. 2023-09-29T08:14:06Z)
|
||||||
|
|
||||||
|
GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false"
|
||||||
|
GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false"
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
module github.com/supabase/auth
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1 // indirect
|
||||||
|
github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40
|
||||||
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
|
||||||
|
github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||||
|
github.com/coreos/go-oidc/v3 v3.6.0
|
||||||
|
github.com/didip/tollbooth/v5 v5.1.1
|
||||||
|
github.com/gobuffalo/validate/v3 v3.3.3 // indirect
|
||||||
|
github.com/gobwas/glob v0.2.3
|
||||||
|
github.com/gofrs/uuid v4.3.1+incompatible
|
||||||
|
github.com/jackc/pgconn v1.14.3
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||||
|
github.com/jmoiron/sqlx v1.3.5
|
||||||
|
github.com/joho/godotenv v1.4.0
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
|
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/pquerna/otp v1.4.0
|
||||||
|
github.com/rs/cors v1.11.0
|
||||||
|
github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35
|
||||||
|
github.com/sethvargo/go-password v0.2.0
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/spf13/cobra v1.6.1
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
golang.org/x/crypto v0.31.0
|
||||||
|
golang.org/x/oauth2 v0.17.0
|
||||||
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bits-and-blooms/bitset v1.10.0 // indirect
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.12 // indirect
|
||||||
|
github.com/gobuffalo/nulls v0.4.2 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.1 // indirect
|
||||||
|
github.com/jackc/pgx/v4 v4.18.2 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.5 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/XSAM/otelsql v0.26.0
|
||||||
|
github.com/bombsimon/logrusr/v3 v3.0.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/runtime v0.45.0
|
||||||
|
go.opentelemetry.io/otel v1.26.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0
|
||||||
|
go.opentelemetry.io/otel/metric v1.26.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.26.0
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.26.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0
|
||||||
|
gopkg.in/h2non/gock.v1 v1.1.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bits-and-blooms/bloom/v3 v3.6.0
|
||||||
|
github.com/crewjam/saml v0.4.14
|
||||||
|
github.com/deepmap/oapi-codegen v1.12.4
|
||||||
|
github.com/fatih/structs v1.1.0
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
|
github.com/go-chi/chi/v5 v5.0.12
|
||||||
|
github.com/go-webauthn/webauthn v0.11.1
|
||||||
|
github.com/gobuffalo/pop/v6 v6.1.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.0
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721
|
||||||
|
github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.26.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.26.0
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.48.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/beevik/etree v1.1.0 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/crewjam/httperr v0.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fatih/color v1.13.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.1 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
|
github.com/gobuffalo/envy v1.10.2 // indirect
|
||||||
|
github.com/gobuffalo/fizz v1.14.4 // indirect
|
||||||
|
github.com/gobuffalo/flect v1.0.2 // indirect
|
||||||
|
github.com/gobuffalo/github_flavored_markdown v1.1.3 // indirect
|
||||||
|
github.com/gobuffalo/helpers v0.6.7 // indirect
|
||||||
|
github.com/gobuffalo/plush/v4 v4.1.18 // indirect
|
||||||
|
github.com/gobuffalo/tags/v3 v3.1.4 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgtype v1.14.0 // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/luna-duclos/instrumentedsql v1.1.3 // indirect
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.19.0
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.48.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||||
|
github.com/russellhaering/goxmldsig v1.3.0 // indirect
|
||||||
|
github.com/sergi/go-diff v1.2.0 // indirect
|
||||||
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
|
||||||
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.2.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb
|
||||||
|
golang.org/x/net v0.23.0 // indirect
|
||||||
|
golang.org/x/sync v0.10.0
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect
|
||||||
|
google.golang.org/appengine v1.6.8 // indirect
|
||||||
|
google.golang.org/grpc v1.63.2 // indirect
|
||||||
|
google.golang.org/protobuf v1.33.0 // indirect
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
go 1.22.3
|
||||||
|
|
@ -0,0 +1,559 @@
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/XSAM/otelsql v0.26.0 h1:UhAGVBD34Ctbh2aYcm/JAdL+6T6ybrP+YMWYkHqCdmo=
|
||||||
|
github.com/XSAM/otelsql v0.26.0/go.mod h1:5ciw61eMSh+RtTPN8spvPEPLJpAErZw8mFFPNfYiaxA=
|
||||||
|
github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40 h1:uz4N2yHL4MF8vZX+36n+tcxeUf8D/gL4aJkyouhDw4A=
|
||||||
|
github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40/go.mod h1:dytw+5qs+pdi61fO/S4OmXR7AuEq/HvNCuG03KxQHT4=
|
||||||
|
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
|
||||||
|
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
|
||||||
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||||
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 h1:vMqcPzLT1/mbYew0gM6EJy4/sCNy9lY9rmlFO+pPwhY=
|
||||||
|
github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62/go.mod h1:r5ZalvRl3tXevRNJkwIB6DC4DD3DMjIlY9NEU1XGoaQ=
|
||||||
|
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||||
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsyepwsSr88=
|
||||||
|
github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/bits-and-blooms/bloom/v3 v3.6.0 h1:dTU0OVLJSoOhz9m68FTXMFfA39nR8U/nTCs1zb26mOI=
|
||||||
|
github.com/bits-and-blooms/bloom/v3 v3.6.0/go.mod h1:VKlUSvp0lFIYqxJjzdnSsZEw4iHb1kOL2tfHTgyJBHg=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
|
github.com/bombsimon/logrusr/v3 v3.0.0 h1:tcAoLfuAhKP9npBxWzSdpsvKPQt1XV02nSf2lZA82TQ=
|
||||||
|
github.com/bombsimon/logrusr/v3 v3.0.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||||
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo=
|
||||||
|
github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
|
||||||
|
github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c=
|
||||||
|
github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||||
|
github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP7g5Q2s=
|
||||||
|
github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas=
|
||||||
|
github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k=
|
||||||
|
github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g=
|
||||||
|
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||||
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||||
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
|
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||||
|
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
|
||||||
|
github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
|
||||||
|
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
|
||||||
|
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
|
||||||
|
github.com/gobuffalo/attrs v1.0.3/go.mod h1:KvDJCE0avbufqS0Bw3UV7RQynESY0jjod+572ctX4t8=
|
||||||
|
github.com/gobuffalo/envy v1.10.2 h1:EIi03p9c3yeuRCFPOKcSfajzkLb3hrRjEpHGI8I2Wo4=
|
||||||
|
github.com/gobuffalo/envy v1.10.2/go.mod h1:qGAGwdvDsaEtPhfBzb3o0SfDea8ByGn9j8bKmVft9z8=
|
||||||
|
github.com/gobuffalo/fizz v1.14.4 h1:8uume7joF6niTNWN582IQ2jhGTUoa9g1fiV/tIoGdBs=
|
||||||
|
github.com/gobuffalo/fizz v1.14.4/go.mod h1:9/2fGNXNeIFOXEEgTPJwiK63e44RjG+Nc4hfMm1ArGM=
|
||||||
|
github.com/gobuffalo/flect v0.3.0/go.mod h1:5pf3aGnsvqvCj50AVni7mJJF8ICxGZ8HomberC3pXLE=
|
||||||
|
github.com/gobuffalo/flect v1.0.0/go.mod h1:l9V6xSb4BlXwsxEMj3FVEub2nkdQjWhPvD8XTTlHPQc=
|
||||||
|
github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA=
|
||||||
|
github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||||
|
github.com/gobuffalo/genny/v2 v2.1.0/go.mod h1:4yoTNk4bYuP3BMM6uQKYPvtP6WsXFGm2w2EFYZdRls8=
|
||||||
|
github.com/gobuffalo/github_flavored_markdown v1.1.3 h1:rSMPtx9ePkFB22vJ+dH+m/EUBS8doQ3S8LeEXcdwZHk=
|
||||||
|
github.com/gobuffalo/github_flavored_markdown v1.1.3/go.mod h1:IzgO5xS6hqkDmUh91BW/+Qxo/qYnvfzoz3A7uLkg77I=
|
||||||
|
github.com/gobuffalo/helpers v0.6.7 h1:C9CedoRSfgWg2ZoIkVXgjI5kgmSpL34Z3qdnzpfNVd8=
|
||||||
|
github.com/gobuffalo/helpers v0.6.7/go.mod h1:j0u1iC1VqlCaJEEVkZN8Ia3TEzfj/zoXANqyJExTMTA=
|
||||||
|
github.com/gobuffalo/logger v1.0.7/go.mod h1:u40u6Bq3VVvaMcy5sRBclD8SXhBYPS0Qk95ubt+1xJM=
|
||||||
|
github.com/gobuffalo/nulls v0.4.2 h1:GAqBR29R3oPY+WCC7JL9KKk9erchaNuV6unsOSZGQkw=
|
||||||
|
github.com/gobuffalo/nulls v0.4.2/go.mod h1:EElw2zmBYafU2R9W4Ii1ByIj177wA/pc0JdjtD0EsH8=
|
||||||
|
github.com/gobuffalo/packd v1.0.2/go.mod h1:sUc61tDqGMXON80zpKGp92lDb86Km28jfvX7IAyxFT8=
|
||||||
|
github.com/gobuffalo/plush/v4 v4.1.16/go.mod h1:6t7swVsarJ8qSLw1qyAH/KbrcSTwdun2ASEQkOznakg=
|
||||||
|
github.com/gobuffalo/plush/v4 v4.1.18 h1:bnPjdMTEUQHqj9TNX2Ck3mxEXYZa+0nrFMNM07kpX9g=
|
||||||
|
github.com/gobuffalo/plush/v4 v4.1.18/go.mod h1:xi2tJIhFI4UdzIL8sxZtzGYOd2xbBpcFbLZlIPGGZhU=
|
||||||
|
github.com/gobuffalo/pop/v6 v6.1.1 h1:eUDBaZcb0gYrmFnKwpuTEUA7t5ZHqNfvS4POqJYXDZY=
|
||||||
|
github.com/gobuffalo/pop/v6 v6.1.1/go.mod h1:1n7jAmI1i7fxuXPZjZb0VBPQDbksRtCoFnrDV5IsvaI=
|
||||||
|
github.com/gobuffalo/tags/v3 v3.1.4 h1:X/ydLLPhgXV4h04Hp2xlbI2oc5MDaa7eub6zw8oHjsM=
|
||||||
|
github.com/gobuffalo/tags/v3 v3.1.4/go.mod h1:ArRNo3ErlHO8BtdA0REaZxijuWnWzF6PUXngmMXd2I0=
|
||||||
|
github.com/gobuffalo/validate/v3 v3.3.3 h1:o7wkIGSvZBYBd6ChQoLxkz2y1pfmhbI4jNJYh6PuNJ4=
|
||||||
|
github.com/gobuffalo/validate/v3 v3.3.3/go.mod h1:YC7FsbJ/9hW/VjQdmXPvFqvRis4vrRYFxr69WiNZw6g=
|
||||||
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI=
|
||||||
|
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||||
|
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM=
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||||
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||||
|
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||||
|
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||||
|
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||||
|
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
|
||||||
|
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
|
||||||
|
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 h1:WAvSpGf7MsFuzAtK4Vk7R4EVe+liW4x83r4oWu0WHKw=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
|
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
|
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||||
|
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||||
|
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||||
|
github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw=
|
||||||
|
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||||
|
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||||
|
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
|
||||||
|
github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU=
|
||||||
|
github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
|
||||||
|
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
|
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
|
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
|
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
|
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||||
|
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||||
|
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
|
||||||
|
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||||
|
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.0 h1:0zs7Ya6+39qoit7gwAf+cYm1zzgS3fceIdo7RmQ5lkw=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.0/go.mod h1:Xpw9QIaUGiIUD1Wx0NcY1sIHwFf8lDuZn/cmxtXYRys=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||||
|
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/luna-duclos/instrumentedsql v1.1.3 h1:t7mvC0z1jUt5A0UQ6I/0H31ryymuQRnJcWCiqV3lSAA=
|
||||||
|
github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs=
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||||
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
|
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||||
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM=
|
||||||
|
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM=
|
||||||
|
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
|
||||||
|
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
|
||||||
|
github.com/patrickmn/go-cache v0.0.0-20170418232947-7ac151875ffb/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
|
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||||
|
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
|
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||||
|
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||||
|
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||||
|
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||||
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
|
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||||
|
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
|
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
|
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||||
|
github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM=
|
||||||
|
github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
|
github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 h1:eajwn6K3weW5cd1ZXLu2sJ4pvwlBiCWY4uDejOr73gM=
|
||||||
|
github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||||
|
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI=
|
||||||
|
github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE=
|
||||||
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||||
|
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
|
||||||
|
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||||
|
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
|
||||||
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||||
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
|
||||||
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||||
|
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
|
||||||
|
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721 h1:HTsFo0buahHfjuVUTPDdJRBkfjExkRM1LUBy6crQ7lc=
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 h1:VDuRtwen5Z7QQ5ctuHUse4wAv/JozkKZkdic5vUV4Lg=
|
||||||
|
github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869/go.mod h1:eHX5nlSMSnyPjUrbYzeqrA8snCe2SKyfizKjU3dkfOw=
|
||||||
|
github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg=
|
||||||
|
github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/runtime v0.45.0 h1:2JydY5UiDpqvj2p7sO9bgHuhTy4hgTZ0ymehdq/Ob0Q=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/runtime v0.45.0/go.mod h1:ch3a5QxOqVWxas4CzjCFFOOQe+7HgAXC/N1oVxS9DK4=
|
||||||
|
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
|
||||||
|
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.26.0 h1:+hm+I+KigBy3M24/h1p/NHkUx/evbLH0PNcjpMyCHc4=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.26.0/go.mod h1:NjC8142mLvvNT6biDpaMjyz78kyEHIwAJlSX0N9P5KI=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.26.0 h1:HGZWGmCVRCVyAs2GQaiHQPbDHo+ObFWeUEOd+zDnp64=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.26.0/go.mod h1:SaH+v38LSCHddyk7RGlU9uZyQoRrKao6IBnJw6Kbn+c=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.48.0 h1:sBQe3VNGUjY9IKWQC6z2lNqa5iGbDSxhs60ABwK4y0s=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.48.0/go.mod h1:DtrbMzoZWwQHyrQmCfLam5DZbnmorsGbOtTbYHycU5o=
|
||||||
|
go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
|
||||||
|
go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.26.0 h1:cWSks5tfriHPdWFnl+qpX3P681aAYqlZHcAyHw5aU9Y=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.26.0/go.mod h1:ClMFFknnThJCksebJwz7KIyEDHO+nTB6gK8obLy8RyE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A=
|
||||||
|
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
|
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
|
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
|
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
|
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
|
||||||
|
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
|
||||||
|
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
|
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||||
|
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||||
|
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||||
|
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w=
|
||||||
|
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20161007143504-f4b625ec9b21/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||||
|
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||||
|
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||||
|
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
||||||
|
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||||
|
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||||
|
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY=
|
||||||
|
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||||
|
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
|
||||||
|
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||||
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||||
|
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||||
|
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||||
|
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
FAIL=false
|
||||||
|
|
||||||
|
for PKG in "crypto"
|
||||||
|
do
|
||||||
|
UNCOVERED_FUNCS=$(go tool cover -func=coverage.out | grep "^github.com/supabase/auth/internal/$PKG/" | grep -v '100.0%$')
|
||||||
|
UNCOVERED_FUNCS_COUNT=$(echo "$UNCOVERED_FUNCS" | wc -l)
|
||||||
|
|
||||||
|
if [ "$UNCOVERED_FUNCS_COUNT" -gt 1 ] # wc -l counts +1 line
|
||||||
|
then
|
||||||
|
echo "Package $PKG not covered 100% with tests. $UNCOVERED_FUNCS_COUNT functions need more tests. This is mandatory."
|
||||||
|
echo "$UNCOVERED_FUNCS"
|
||||||
|
FAIL=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAIL" = "true" ]
|
||||||
|
then
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
postgres:
|
||||||
|
dialect: "postgres"
|
||||||
|
database: "postgres"
|
||||||
|
host: {{ envOr "POSTGRES_HOST" "127.0.0.1" }}
|
||||||
|
port: {{ envOr "POSTGRES_PORT" "5432" }}
|
||||||
|
user: {{ envOr "POSTGRES_USER" "postgres" }}
|
||||||
|
password: {{ envOr "POSTGRES_PASSWORD" "root" }}
|
||||||
|
|
||||||
|
test:
|
||||||
|
dialect: "postgres"
|
||||||
|
database: "postgres"
|
||||||
|
host: {{ envOr "POSTGRES_HOST" "127.0.0.1" }}
|
||||||
|
port: {{ envOr "POSTGRES_PORT" "5432" }}
|
||||||
|
user: {{ envOr "POSTGRES_USER" "postgres" }}
|
||||||
|
password: {{ envOr "POSTGRES_PASSWORD" "root" }}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE USER supabase_admin LOGIN CREATEROLE CREATEDB REPLICATION BYPASSRLS;
|
||||||
|
|
||||||
|
-- Supabase super admin
|
||||||
|
CREATE USER supabase_auth_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION PASSWORD 'root';
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_auth_admin;
|
||||||
|
GRANT CREATE ON DATABASE postgres TO supabase_auth_admin;
|
||||||
|
ALTER USER supabase_auth_admin SET search_path = 'auth';
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
DB_ENV=$1
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
DATABASE="$DIR/database.yml"
|
||||||
|
|
||||||
|
export GOTRUE_DB_DRIVER="postgres"
|
||||||
|
export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/$DB_ENV"
|
||||||
|
export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations
|
||||||
|
|
||||||
|
go run main.go migrate -c $DIR/test.env
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker rm -f gotrue_postgresql >/dev/null 2>/dev/null || true
|
||||||
|
|
||||||
|
docker volume inspect postgres_data 2>/dev/null >/dev/null || docker volume create --name postgres_data >/dev/null
|
||||||
|
|
||||||
|
docker run --name gotrue_postgresql \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=root \
|
||||||
|
-e POSTGRES_DB=postgres \
|
||||||
|
--volume postgres_data:/var/lib/postgresql/data \
|
||||||
|
--volume "$(pwd)"/hack/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql \
|
||||||
|
-d postgres:15
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
GOTRUE_JWT_SECRET=testsecret
|
||||||
|
GOTRUE_JWT_EXP=3600
|
||||||
|
GOTRUE_JWT_AUD="authenticated"
|
||||||
|
GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role"
|
||||||
|
GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated"
|
||||||
|
GOTRUE_DB_DRIVER=postgres
|
||||||
|
DB_NAMESPACE="auth"
|
||||||
|
GOTRUE_DB_AUTOMIGRATE=true
|
||||||
|
DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres"
|
||||||
|
GOTRUE_API_HOST=localhost
|
||||||
|
PORT=9999
|
||||||
|
API_EXTERNAL_URL="http://localhost:9999"
|
||||||
|
GOTRUE_LOG_SQL=none
|
||||||
|
GOTRUE_LOG_LEVEL=warn
|
||||||
|
GOTRUE_SITE_URL=https://example.netlify.com
|
||||||
|
GOTRUE_URI_ALLOW_LIST="http://localhost:3000"
|
||||||
|
GOTRUE_OPERATOR_TOKEN=foobar
|
||||||
|
GOTRUE_EXTERNAL_APPLE_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_APPLE_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_AZURE_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_AZURE_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_AZURE_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_BITBUCKET_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_FLY_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_FLY_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_FLY_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_FLY_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_FIGMA_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_URL=https://keycloak.example.com/auth/realms/myrealm
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_OIDC_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_OIDC_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_OIDC_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_OIDC_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_GITLAB_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_NOTION_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_NOTION_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_NOTION_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_NOTION_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_SLACK_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_SLACK_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_SLACK_OIDC_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_SLACK_OIDC_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_SLACK_OIDC_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_SLACK_OIDC_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_TWITCH_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=testclientid
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_SECRET=testsecret
|
||||||
|
GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI=https://identity.services.netlify.com/callback
|
||||||
|
GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION="300s"
|
||||||
|
GOTRUE_RATE_LIMIT_VERIFY="100000"
|
||||||
|
GOTRUE_RATE_LIMIT_TOKEN_REFRESH="30"
|
||||||
|
GOTRUE_RATE_LIMIT_ANONYMOUS_USERS="5"
|
||||||
|
GOTRUE_RATE_LIMIT_HEADER="My-Custom-Header"
|
||||||
|
GOTRUE_TRACING_ENABLED=true
|
||||||
|
GOTRUE_TRACING_EXPORTER=default
|
||||||
|
GOTRUE_TRACING_HOST=127.0.0.1
|
||||||
|
GOTRUE_TRACING_PORT=8126
|
||||||
|
GOTRUE_TRACING_TAGS="env:test"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
|
||||||
|
GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"
|
||||||
|
GOTRUE_SAML_ENABLED="true"
|
||||||
|
GOTRUE_SAML_PRIVATE_KEY="MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP"
|
||||||
|
GOTRUE_MAX_VERIFIED_FACTORS=10
|
||||||
|
GOTRUE_SMS_TEST_OTP_VALID_UNTIL=""
|
||||||
|
GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT=true
|
||||||
|
GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID=abc
|
||||||
|
GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY=pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4
|
||||||
|
GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS=abc:pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
CREATE USER supabase_admin LOGIN CREATEROLE CREATEDB REPLICATION BYPASSRLS;
|
||||||
|
|
||||||
|
-- Supabase super admin
|
||||||
|
CREATE USER supabase_auth_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION PASSWORD 'root';
|
||||||
|
CREATE SCHEMA IF NOT EXISTS $DB_NAMESPACE AUTHORIZATION supabase_auth_admin;
|
||||||
|
GRANT CREATE ON DATABASE postgres TO supabase_auth_admin;
|
||||||
|
ALTER USER supabase_auth_admin SET search_path = '$DB_NAMESPACE';
|
||||||
|
EOSQL
|
||||||
|
|
@ -0,0 +1,642 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sethvargo/go-password/password"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminUserParams struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
EmailConfirm bool `json:"email_confirm"`
|
||||||
|
PhoneConfirm bool `json:"phone_confirm"`
|
||||||
|
UserMetaData map[string]interface{} `json:"user_metadata"`
|
||||||
|
AppMetaData map[string]interface{} `json:"app_metadata"`
|
||||||
|
BanDuration string `json:"ban_duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminUserDeleteParams struct {
|
||||||
|
ShouldSoftDelete bool `json:"should_soft_delete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminUserUpdateFactorParams struct {
|
||||||
|
FriendlyName string `json:"friendly_name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminListUsersResponse struct {
|
||||||
|
Users []*models.User `json:"users"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
|
||||||
|
userID, err := uuid.FromString(chi.URLParam(r, "user_id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, notFoundError(ErrorCodeValidationFailed, "user_id must be an UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
observability.LogEntrySetField(r, "user_id", userID)
|
||||||
|
|
||||||
|
u, err := models.FindUserByID(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return nil, notFoundError(ErrorCodeUserNotFound, "User not found")
|
||||||
|
}
|
||||||
|
return nil, internalServerError("Database error loading user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return withUser(ctx, u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use only after requireAuthentication, so that there is a valid user
|
||||||
|
func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
user := getUser(ctx)
|
||||||
|
factorID, err := uuid.FromString(chi.URLParam(r, "factor_id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, notFoundError(ErrorCodeValidationFailed, "factor_id must be an UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
observability.LogEntrySetField(r, "factor_id", factorID)
|
||||||
|
|
||||||
|
factor, err := user.FindOwnedFactorByID(db, factorID)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return nil, notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found")
|
||||||
|
}
|
||||||
|
return nil, internalServerError("Database error loading factor").WithInternalError(err)
|
||||||
|
}
|
||||||
|
return withFactor(ctx, factor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) {
|
||||||
|
params := &AdminUserParams{}
|
||||||
|
if err := retrieveRequestParams(r, params); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUsers responds with a list of all users in a given audience
|
||||||
|
func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
aud := a.requestAud(ctx, r)
|
||||||
|
|
||||||
|
pageParams, err := paginate(r)
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Bad Pagination Parameters: %v", err).WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{{Name: models.CreatedAt, Dir: models.Descending}})
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Bad Sort Parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := r.URL.Query().Get("filter")
|
||||||
|
|
||||||
|
users, err := models.FindUsersInAudience(db, aud, pageParams, sortParams, filter)
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Database error finding users").WithInternalError(err)
|
||||||
|
}
|
||||||
|
addPaginationHeaders(w, r, pageParams)
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, AdminListUsersResponse{
|
||||||
|
Users: users,
|
||||||
|
Aud: aud,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserGet returns information about a single user
|
||||||
|
func (a *API) adminUserGet(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := getUser(r.Context())
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserUpdate updates a single user object
|
||||||
|
func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
config := a.config
|
||||||
|
user := getUser(ctx)
|
||||||
|
adminUser := getAdminUser(ctx)
|
||||||
|
params, err := a.getAdminParams(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Email != "" {
|
||||||
|
params.Email, err = a.validateEmail(params.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Phone != "" {
|
||||||
|
params.Phone, err = validatePhone(params.Phone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var banDuration *time.Duration
|
||||||
|
if params.BanDuration != "" {
|
||||||
|
duration := time.Duration(0)
|
||||||
|
if params.BanDuration != "none" {
|
||||||
|
duration, err = time.ParseDuration(params.BanDuration)
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
banDuration = &duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Password != nil {
|
||||||
|
password := *params.Password
|
||||||
|
|
||||||
|
if err := a.checkPasswordStrength(ctx, password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if params.Role != "" {
|
||||||
|
if terr := user.SetRole(tx, params.Role); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.EmailConfirm {
|
||||||
|
if terr := user.Confirm(tx); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.PhoneConfirm {
|
||||||
|
if terr := user.ConfirmPhone(tx); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Password != nil {
|
||||||
|
if terr := user.UpdatePassword(tx, nil); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var identities []models.Identity
|
||||||
|
if params.Email != "" {
|
||||||
|
if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil && !models.IsNotFoundError(terr) {
|
||||||
|
return terr
|
||||||
|
} else if identity == nil {
|
||||||
|
// if the user doesn't have an existing email
|
||||||
|
// then updating the user's email should create a new email identity
|
||||||
|
i, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
Email: params.Email,
|
||||||
|
EmailVerified: params.EmailConfirm,
|
||||||
|
}))
|
||||||
|
if terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
identities = append(identities, *i)
|
||||||
|
} else {
|
||||||
|
// update the existing email identity
|
||||||
|
if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
|
||||||
|
"email": params.Email,
|
||||||
|
"email_verified": params.EmailConfirm,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.IsAnonymous && params.EmailConfirm {
|
||||||
|
user.IsAnonymous = false
|
||||||
|
if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := user.SetEmail(tx, params.Email); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Phone != "" {
|
||||||
|
if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "phone"); terr != nil && !models.IsNotFoundError(terr) {
|
||||||
|
return terr
|
||||||
|
} else if identity == nil {
|
||||||
|
// if the user doesn't have an existing phone
|
||||||
|
// then updating the user's phone should create a new phone identity
|
||||||
|
identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
Phone: params.Phone,
|
||||||
|
PhoneVerified: params.PhoneConfirm,
|
||||||
|
}))
|
||||||
|
if terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
identities = append(identities, *identity)
|
||||||
|
} else {
|
||||||
|
// update the existing phone identity
|
||||||
|
if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
|
||||||
|
"phone": params.Phone,
|
||||||
|
"phone_verified": params.PhoneConfirm,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.IsAnonymous && params.PhoneConfirm {
|
||||||
|
user.IsAnonymous = false
|
||||||
|
if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if terr := user.SetPhone(tx, params.Phone); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Identities = append(user.Identities, identities...)
|
||||||
|
|
||||||
|
if params.AppMetaData != nil {
|
||||||
|
if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.UserMetaData != nil {
|
||||||
|
if terr := user.UpdateUserMetaData(tx, params.UserMetaData); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if banDuration != nil {
|
||||||
|
if terr := user.Ban(tx, *banDuration); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserModifiedAction, "", map[string]interface{}{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"user_email": user.Email,
|
||||||
|
"user_phone": user.Phone,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Error updating user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserCreate creates a new user based on the provided data
|
||||||
|
func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
config := a.config
|
||||||
|
|
||||||
|
adminUser := getAdminUser(ctx)
|
||||||
|
params, err := a.getAdminParams(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aud := a.requestAud(ctx, r)
|
||||||
|
if params.Aud != "" {
|
||||||
|
aud = params.Aud
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Email == "" && params.Phone == "" {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Cannot create a user without either an email or phone")
|
||||||
|
}
|
||||||
|
|
||||||
|
var providers []string
|
||||||
|
if params.Email != "" {
|
||||||
|
params.Email, err = a.validateEmail(params.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user, err := models.IsDuplicatedEmail(db, params.Email, aud, nil); err != nil {
|
||||||
|
return internalServerError("Database error checking email").WithInternalError(err)
|
||||||
|
} else if user != nil {
|
||||||
|
return unprocessableEntityError(ErrorCodeEmailExists, DuplicateEmailMsg)
|
||||||
|
}
|
||||||
|
providers = append(providers, "email")
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Phone != "" {
|
||||||
|
params.Phone, err = validatePhone(params.Phone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists, err := models.IsDuplicatedPhone(db, params.Phone, aud); err != nil {
|
||||||
|
return internalServerError("Database error checking phone").WithInternalError(err)
|
||||||
|
} else if exists {
|
||||||
|
return unprocessableEntityError(ErrorCodePhoneExists, "Phone number already registered by another user")
|
||||||
|
}
|
||||||
|
providers = append(providers, "phone")
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Password != nil && params.PasswordHash != "" {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Only a password or a password hash should be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.Password == nil || *params.Password == "") && params.PasswordHash == "" {
|
||||||
|
password, err := password.Generate(64, 10, 0, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Error generating password").WithInternalError(err)
|
||||||
|
}
|
||||||
|
params.Password = &password
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *models.User
|
||||||
|
if params.PasswordHash != "" {
|
||||||
|
user, err = models.NewUserWithPasswordHash(params.Phone, params.Email, params.PasswordHash, aud, params.UserMetaData)
|
||||||
|
} else {
|
||||||
|
user, err = models.NewUser(params.Phone, params.Email, *params.Password, aud, params.UserMetaData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, bcrypt.ErrPasswordTooLong) {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, err.Error())
|
||||||
|
}
|
||||||
|
return internalServerError("Error creating user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Id != "" {
|
||||||
|
customId, err := uuid.FromString(params.Id)
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "ID must conform to the uuid v4 format")
|
||||||
|
}
|
||||||
|
if customId == uuid.Nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "ID cannot be a nil uuid")
|
||||||
|
}
|
||||||
|
user.ID = customId
|
||||||
|
}
|
||||||
|
|
||||||
|
user.AppMetaData = map[string]interface{}{
|
||||||
|
// TODO: Deprecate "provider" field
|
||||||
|
// default to the first provider in the providers slice
|
||||||
|
"provider": providers[0],
|
||||||
|
"providers": providers,
|
||||||
|
}
|
||||||
|
|
||||||
|
var banDuration *time.Duration
|
||||||
|
if params.BanDuration != "" {
|
||||||
|
duration := time.Duration(0)
|
||||||
|
if params.BanDuration != "none" {
|
||||||
|
duration, err = time.ParseDuration(params.BanDuration)
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "invalid format for ban duration: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
banDuration = &duration
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if terr := tx.Create(user); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
var identities []models.Identity
|
||||||
|
if user.GetEmail() != "" {
|
||||||
|
identity, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
Email: user.GetEmail(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
identities = append(identities, *identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.GetPhone() != "" {
|
||||||
|
identity, terr := a.createNewIdentity(tx, user, "phone", structs.Map(provider.Claims{
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
Phone: user.GetPhone(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
identities = append(identities, *identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Identities = identities
|
||||||
|
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserSignedUpAction, "", map[string]interface{}{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"user_email": user.Email,
|
||||||
|
"user_phone": user.Phone,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
role := config.JWT.DefaultGroupName
|
||||||
|
if params.Role != "" {
|
||||||
|
role = params.Role
|
||||||
|
}
|
||||||
|
if terr := user.SetRole(tx, role); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.AppMetaData != nil {
|
||||||
|
if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.EmailConfirm {
|
||||||
|
if terr := user.Confirm(tx); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.PhoneConfirm {
|
||||||
|
if terr := user.ConfirmPhone(tx); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if banDuration != nil {
|
||||||
|
if terr := user.Ban(tx, *banDuration); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Database error creating new user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserDelete deletes a user
|
||||||
|
func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
user := getUser(ctx)
|
||||||
|
adminUser := getAdminUser(ctx)
|
||||||
|
|
||||||
|
// ShouldSoftDelete defaults to false
|
||||||
|
params := &adminUserDeleteParams{}
|
||||||
|
if body, _ := utilities.GetBodyBytes(r); len(body) != 0 {
|
||||||
|
// we only want to parse the body if it's not empty
|
||||||
|
// retrieveRequestParams will handle any errors with stream
|
||||||
|
if err := retrieveRequestParams(r, params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserDeletedAction, "", map[string]interface{}{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"user_email": user.Email,
|
||||||
|
"user_phone": user.Phone,
|
||||||
|
}); terr != nil {
|
||||||
|
return internalServerError("Error recording audit log entry").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.ShouldSoftDelete {
|
||||||
|
if user.DeletedAt != nil {
|
||||||
|
// user has been soft deleted already
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if terr := user.SoftDeleteUser(tx); terr != nil {
|
||||||
|
return internalServerError("Error soft deleting user").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := user.SoftDeleteUserIdentities(tx); terr != nil {
|
||||||
|
return internalServerError("Error soft deleting user identities").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hard delete all associated factors
|
||||||
|
if terr := models.DeleteFactorsByUserId(tx, user.ID); terr != nil {
|
||||||
|
return internalServerError("Error deleting user's factors").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
// hard delete all associated sessions
|
||||||
|
if terr := models.Logout(tx, user.ID); terr != nil {
|
||||||
|
return internalServerError("Error deleting user's sessions").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if terr := tx.Destroy(user); terr != nil {
|
||||||
|
return internalServerError("Database error deleting user").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, map[string]interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminUserDeleteFactor(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
user := getUser(ctx)
|
||||||
|
factor := getFactor(ctx)
|
||||||
|
|
||||||
|
err := a.db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, user, models.DeleteFactorAction, r.RemoteAddr, map[string]interface{}{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"factor_id": factor.ID,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
if terr := tx.Destroy(factor); terr != nil {
|
||||||
|
return internalServerError("Database error deleting factor").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendJSON(w, http.StatusOK, factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminUserGetFactors(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
user := getUser(ctx)
|
||||||
|
return sendJSON(w, http.StatusOK, user.Factors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserUpdate updates a single factor object
|
||||||
|
func (a *API) adminUserUpdateFactor(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
factor := getFactor(ctx)
|
||||||
|
user := getUser(ctx)
|
||||||
|
adminUser := getAdminUser(ctx)
|
||||||
|
params := &adminUserUpdateFactorParams{}
|
||||||
|
|
||||||
|
if err := retrieveRequestParams(r, params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if params.FriendlyName != "" {
|
||||||
|
if terr := factor.UpdateFriendlyName(tx, params.FriendlyName); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Phone != "" && factor.IsPhoneFactor() {
|
||||||
|
phone, err := validatePhone(params.Phone)
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Invalid phone number format (E.164 required)")
|
||||||
|
}
|
||||||
|
if terr := factor.UpdatePhone(tx, phone); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UpdateFactorAction, "", map[string]interface{}{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"factor_id": factor.ID,
|
||||||
|
"factor_type": factor.FactorType,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, factor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,915 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
User *models.User
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdmin(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &AdminTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
ts.Config.External.Email.Enabled = true
|
||||||
|
claims := &AccessTokenClaims{
|
||||||
|
Role: "supabase_admin",
|
||||||
|
}
|
||||||
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
|
||||||
|
require.NoError(ts.T(), err, "Error generating admin jwt")
|
||||||
|
ts.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsersUnauthorized tests API /admin/users route without authentication
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsersUnauthorized() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusUnauthorized, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers tests API /admin/users route
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsers() {
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
assert.Equal(ts.T(), "</admin/users?page=0>; rel=\"last\"", w.Header().Get("Link"))
|
||||||
|
assert.Equal(ts.T(), "0", w.Header().Get("X-Total-Count"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers tests API /admin/users route
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsers_Pagination() {
|
||||||
|
u, err := models.NewUser("12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
u, err = models.NewUser("987654321", "test2@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users?per_page=1", nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
assert.Equal(ts.T(), "</admin/users?page=2&per_page=1>; rel=\"next\", </admin/users?page=2&per_page=1>; rel=\"last\"", w.Header().Get("Link"))
|
||||||
|
assert.Equal(ts.T(), "2", w.Header().Get("X-Total-Count"))
|
||||||
|
|
||||||
|
data := make(map[string]interface{})
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
for _, user := range data["users"].([]interface{}) {
|
||||||
|
assert.NotEmpty(ts.T(), user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers tests API /admin/users route
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsers_SortAsc() {
|
||||||
|
u, err := models.NewUser("", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
u.CreatedAt = time.Now().Add(-time.Minute)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
u, err = models.NewUser("", "test2@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
u.CreatedAt = time.Now()
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||||
|
qv := req.URL.Query()
|
||||||
|
qv.Set("sort", "created_at asc")
|
||||||
|
req.URL.RawQuery = qv.Encode()
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Users []*models.User `json:"users"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
}{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
require.Len(ts.T(), data.Users, 2)
|
||||||
|
assert.Equal(ts.T(), "test1@example.com", data.Users[0].GetEmail())
|
||||||
|
assert.Equal(ts.T(), "test2@example.com", data.Users[1].GetEmail())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers tests API /admin/users route
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsers_SortDesc() {
|
||||||
|
u, err := models.NewUser("12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
u.CreatedAt = time.Now().Add(-time.Minute)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
u, err = models.NewUser("987654321", "test2@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
u.CreatedAt = time.Now()
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Users []*models.User `json:"users"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
}{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
require.Len(ts.T(), data.Users, 2)
|
||||||
|
assert.Equal(ts.T(), "test2@example.com", data.Users[0].GetEmail())
|
||||||
|
assert.Equal(ts.T(), "test1@example.com", data.Users[1].GetEmail())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers tests API /admin/users route
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsers_FilterEmail() {
|
||||||
|
u, err := models.NewUser("", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users?filter=test1", nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Users []*models.User `json:"users"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
}{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
require.Len(ts.T(), data.Users, 1)
|
||||||
|
assert.Equal(ts.T(), "test1@example.com", data.Users[0].GetEmail())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUsers tests API /admin/users route
|
||||||
|
func (ts *AdminTestSuite) TestAdminUsers_FilterName() {
|
||||||
|
u, err := models.NewUser("", "test1@example.com", "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"})
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
u, err = models.NewUser("", "test2@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/users?filter=User", nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Users []*models.User `json:"users"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
}{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
require.Len(ts.T(), data.Users, 1)
|
||||||
|
assert.Equal(ts.T(), "test1@example.com", data.Users[0].GetEmail())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUserCreate tests API /admin/user route (POST)
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserCreate() {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
params map[string]interface{}
|
||||||
|
expected map[string]interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "Only phone",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"phone": "123456789",
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "",
|
||||||
|
"phone": "123456789",
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"provider": "phone",
|
||||||
|
"providers": []string{"phone"},
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "With password",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test1@example.com",
|
||||||
|
"phone": "123456789",
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "test1@example.com",
|
||||||
|
"phone": "123456789",
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"provider": "email",
|
||||||
|
"providers": []string{"email", "phone"},
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Without password",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test2@example.com",
|
||||||
|
"phone": "",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "test2@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"isAuthenticated": false,
|
||||||
|
"provider": "email",
|
||||||
|
"providers": []string{"email"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "With empty string password",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test3@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"password": "",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "test3@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"isAuthenticated": false,
|
||||||
|
"provider": "email",
|
||||||
|
"providers": []string{"email"},
|
||||||
|
"password": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Ban created user",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test4@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"password": "test1",
|
||||||
|
"ban_duration": "24h",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "test4@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"provider": "email",
|
||||||
|
"providers": []string{"email"},
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "With password hash",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test5@example.com",
|
||||||
|
"password_hash": "$2y$10$SXEz2HeT8PUIGQXo9yeUIem8KzNxgG0d7o/.eGj2rj8KbRgAuRVlq",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "test5@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"provider": "email",
|
||||||
|
"providers": []string{"email"},
|
||||||
|
"password": "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "With custom id",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"id": "fc56ab41-2010-4870-a9b9-767c1dc573fb",
|
||||||
|
"email": "test6@example.com",
|
||||||
|
"password": "test",
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"id": "fc56ab41-2010-4870-a9b9-767c1dc573fb",
|
||||||
|
"email": "test6@example.com",
|
||||||
|
"phone": "",
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"provider": "email",
|
||||||
|
"providers": []string{"email"},
|
||||||
|
"password": "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
ts.Config.External.Phone.Enabled = true
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := models.User{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
assert.Equal(ts.T(), c.expected["email"], data.GetEmail())
|
||||||
|
assert.Equal(ts.T(), c.expected["phone"], data.GetPhone())
|
||||||
|
assert.Equal(ts.T(), c.expected["provider"], data.AppMetaData["provider"])
|
||||||
|
assert.ElementsMatch(ts.T(), c.expected["providers"], data.AppMetaData["providers"])
|
||||||
|
|
||||||
|
u, err := models.FindUserByID(ts.API.db, data.ID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// verify that the corresponding identities were created
|
||||||
|
require.NotEmpty(ts.T(), u.Identities)
|
||||||
|
for _, identity := range u.Identities {
|
||||||
|
require.Equal(ts.T(), u.ID, identity.UserID)
|
||||||
|
if identity.Provider == "email" {
|
||||||
|
require.Equal(ts.T(), c.expected["email"], identity.IdentityData["email"])
|
||||||
|
}
|
||||||
|
if identity.Provider == "phone" {
|
||||||
|
require.Equal(ts.T(), c.expected["phone"], identity.IdentityData["phone"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := c.expected["password"]; ok {
|
||||||
|
expectedPassword := fmt.Sprintf("%v", c.expected["password"])
|
||||||
|
isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, expectedPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Equal(ts.T(), c.expected["isAuthenticated"], isAuthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id, ok := c.expected["id"]; ok {
|
||||||
|
uid, err := uuid.FromString(id.(string))
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Equal(ts.T(), uid, data.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove created user after each case
|
||||||
|
require.NoError(ts.T(), ts.API.db.Destroy(u))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUserGet tests API /admin/user route (GET)
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserGet() {
|
||||||
|
u, err := models.NewUser("12345678", "test1@example.com", "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test Get User"})
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/admin/users/%s", u.ID), nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := make(map[string]interface{})
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
assert.Equal(ts.T(), data["email"], "test1@example.com")
|
||||||
|
assert.NotNil(ts.T(), data["app_metadata"])
|
||||||
|
assert.NotNil(ts.T(), data["user_metadata"])
|
||||||
|
md := data["user_metadata"].(map[string]interface{})
|
||||||
|
assert.Len(ts.T(), md, 1)
|
||||||
|
assert.Equal(ts.T(), "Test Get User", md["full_name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUserUpdate tests API /admin/user route (UPDATE)
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserUpdate() {
|
||||||
|
u, err := models.NewUser("12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
newEmail := "test2@example.com"
|
||||||
|
newPhone := "234567890"
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"role": "testing",
|
||||||
|
"app_metadata": map[string]interface{}{
|
||||||
|
"roles": []string{"writer", "editor"},
|
||||||
|
},
|
||||||
|
"user_metadata": map[string]interface{}{
|
||||||
|
"name": "David",
|
||||||
|
},
|
||||||
|
"ban_duration": "24h",
|
||||||
|
"email": newEmail,
|
||||||
|
"phone": newPhone,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := models.User{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
assert.Equal(ts.T(), "testing", data.Role)
|
||||||
|
assert.NotNil(ts.T(), data.UserMetaData)
|
||||||
|
assert.Equal(ts.T(), "David", data.UserMetaData["name"])
|
||||||
|
assert.Equal(ts.T(), newEmail, data.GetEmail())
|
||||||
|
assert.Equal(ts.T(), newPhone, data.GetPhone())
|
||||||
|
|
||||||
|
assert.NotNil(ts.T(), data.AppMetaData)
|
||||||
|
assert.Len(ts.T(), data.AppMetaData["roles"], 2)
|
||||||
|
assert.Contains(ts.T(), data.AppMetaData["roles"], "writer")
|
||||||
|
assert.Contains(ts.T(), data.AppMetaData["roles"], "editor")
|
||||||
|
assert.NotNil(ts.T(), data.BannedUntil)
|
||||||
|
|
||||||
|
u, err = models.FindUserByID(ts.API.db, data.ID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// check if the corresponding identities were successfully created
|
||||||
|
require.NotEmpty(ts.T(), u.Identities)
|
||||||
|
|
||||||
|
for _, identity := range u.Identities {
|
||||||
|
// for email & phone identities, the providerId is the same as the userId
|
||||||
|
require.Equal(ts.T(), u.ID.String(), identity.ProviderID)
|
||||||
|
require.Equal(ts.T(), u.ID, identity.UserID)
|
||||||
|
if identity.Provider == "email" {
|
||||||
|
require.Equal(ts.T(), newEmail, identity.IdentityData["email"])
|
||||||
|
}
|
||||||
|
if identity.Provider == "phone" {
|
||||||
|
require.Equal(ts.T(), newPhone, identity.IdentityData["phone"])
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() {
|
||||||
|
u, err := models.NewUser("12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
|
||||||
|
ts.Config.Password.MinLength = 6
|
||||||
|
ts.Run("Password doesn't meet minimum length", func() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"password": "",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, updateEndpoint, &buffer)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserUpdateBannedUntilFailed() {
|
||||||
|
u, err := models.NewUser("", "test1@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
|
||||||
|
ts.Config.Password.MinLength = 6
|
||||||
|
ts.Run("Incorrect format for ban_duration", func() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"ban_duration": "24",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, updateEndpoint, &buffer)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusBadRequest, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUserDelete tests API /admin/users route (DELETE)
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserDelete() {
|
||||||
|
type expected struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
signupParams := &SignupParams{
|
||||||
|
Email: "test-delete@example.com",
|
||||||
|
Password: "test",
|
||||||
|
Data: map[string]interface{}{"name": "test"},
|
||||||
|
Provider: "email",
|
||||||
|
Aud: ts.Config.JWT.Aud,
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
body map[string]interface{}
|
||||||
|
isSoftDelete string
|
||||||
|
isSSOUser bool
|
||||||
|
expected expected
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "Test admin delete user (default)",
|
||||||
|
isSoftDelete: "",
|
||||||
|
isSSOUser: false,
|
||||||
|
expected: expected{code: http.StatusOK, err: models.UserNotFoundError{}},
|
||||||
|
body: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Test admin delete user (hard deletion)",
|
||||||
|
isSoftDelete: "?is_soft_delete=false",
|
||||||
|
isSSOUser: false,
|
||||||
|
expected: expected{code: http.StatusOK, err: models.UserNotFoundError{}},
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"should_soft_delete": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Test admin delete user (soft deletion)",
|
||||||
|
isSoftDelete: "?is_soft_delete=true",
|
||||||
|
isSSOUser: false,
|
||||||
|
expected: expected{code: http.StatusOK, err: models.UserNotFoundError{}},
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"should_soft_delete": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Test admin delete user (soft deletion & sso user)",
|
||||||
|
isSoftDelete: "?is_soft_delete=true",
|
||||||
|
isSSOUser: true,
|
||||||
|
expected: expected{code: http.StatusOK, err: nil},
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"should_soft_delete": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
|
||||||
|
u, err := signupParams.ToUserModel(false /* <- isSSOUser */)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
u, err = ts.API.signupNewUser(ts.API.db, u)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), c.expected.code, w.Code)
|
||||||
|
|
||||||
|
if c.isSSOUser {
|
||||||
|
u, err = models.FindUserByID(ts.API.db, u.ID)
|
||||||
|
require.NotNil(ts.T(), u)
|
||||||
|
} else {
|
||||||
|
_, err = models.FindUserByEmailAndAudience(ts.API.db, signupParams.Email, ts.Config.JWT.Aud)
|
||||||
|
}
|
||||||
|
require.Equal(ts.T(), c.expected.err, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserSoftDeletion() {
|
||||||
|
// create user
|
||||||
|
u, err := models.NewUser("123456789", "test@example.com", "secret", ts.Config.JWT.Aud, map[string]interface{}{"name": "test"})
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
u.ConfirmationToken = "some_token"
|
||||||
|
u.RecoveryToken = "some_token"
|
||||||
|
u.EmailChangeTokenCurrent = "some_token"
|
||||||
|
u.EmailChangeTokenNew = "some_token"
|
||||||
|
u.PhoneChangeToken = "some_token"
|
||||||
|
u.AppMetaData = map[string]interface{}{
|
||||||
|
"provider": "email",
|
||||||
|
}
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u))
|
||||||
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken))
|
||||||
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken))
|
||||||
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent))
|
||||||
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenNew, models.EmailChangeTokenNew))
|
||||||
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetPhone(), u.PhoneChangeToken, models.PhoneChangeToken))
|
||||||
|
|
||||||
|
// create user identities
|
||||||
|
_, err = ts.API.createNewIdentity(ts.API.db, u, "email", map[string]interface{}{
|
||||||
|
"sub": "123456",
|
||||||
|
"email": "test@example.com",
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
_, err = ts.API.createNewIdentity(ts.API.db, u, "github", map[string]interface{}{
|
||||||
|
"sub": "234567",
|
||||||
|
"email": "test@example.com",
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"should_soft_delete": true,
|
||||||
|
}))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// get soft-deleted user from db
|
||||||
|
deletedUser, err := models.FindUserByID(ts.API.db, u.ID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
require.Empty(ts.T(), deletedUser.ConfirmationToken)
|
||||||
|
require.Empty(ts.T(), deletedUser.RecoveryToken)
|
||||||
|
require.Empty(ts.T(), deletedUser.EmailChangeTokenCurrent)
|
||||||
|
require.Empty(ts.T(), deletedUser.EmailChangeTokenNew)
|
||||||
|
require.Empty(ts.T(), deletedUser.EncryptedPassword)
|
||||||
|
require.Empty(ts.T(), deletedUser.PhoneChangeToken)
|
||||||
|
require.Empty(ts.T(), deletedUser.UserMetaData)
|
||||||
|
require.Empty(ts.T(), deletedUser.AppMetaData)
|
||||||
|
require.NotEmpty(ts.T(), deletedUser.DeletedAt)
|
||||||
|
require.NotEmpty(ts.T(), deletedUser.GetEmail())
|
||||||
|
|
||||||
|
// get soft-deleted user's identity from db
|
||||||
|
deletedIdentities, err := models.FindIdentitiesByUserID(ts.API.db, deletedUser.ID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
for _, identity := range deletedIdentities {
|
||||||
|
require.Empty(ts.T(), identity.IdentityData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
|
||||||
|
var cases = []struct {
|
||||||
|
desc string
|
||||||
|
customConfig *conf.GlobalConfiguration
|
||||||
|
userData map[string]interface{}
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "Email Signups Disabled",
|
||||||
|
customConfig: &conf.GlobalConfiguration{
|
||||||
|
JWT: ts.Config.JWT,
|
||||||
|
External: conf.ProviderConfiguration{
|
||||||
|
Email: conf.EmailProviderConfiguration{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userData: map[string]interface{}{
|
||||||
|
"email": "test1@example.com",
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
expected: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Phone Signups Disabled",
|
||||||
|
customConfig: &conf.GlobalConfiguration{
|
||||||
|
JWT: ts.Config.JWT,
|
||||||
|
External: conf.ProviderConfiguration{
|
||||||
|
Phone: conf.PhoneProviderConfiguration{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userData: map[string]interface{}{
|
||||||
|
"phone": "123456789",
|
||||||
|
"password": "test1",
|
||||||
|
},
|
||||||
|
expected: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "All Signups Disabled",
|
||||||
|
customConfig: &conf.GlobalConfiguration{
|
||||||
|
JWT: ts.Config.JWT,
|
||||||
|
DisableSignup: true,
|
||||||
|
},
|
||||||
|
userData: map[string]interface{}{
|
||||||
|
"email": "test2@example.com",
|
||||||
|
"password": "test2",
|
||||||
|
},
|
||||||
|
expected: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
// Initialize user data
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.userData))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.Config.JWT = c.customConfig.JWT
|
||||||
|
ts.Config.External = c.customConfig.External
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), c.expected, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUserDeleteFactor tests API /admin/users/<user_id>/factors/<factor_id>/
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserDeleteFactor() {
|
||||||
|
u, err := models.NewUser("123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
f := models.NewTOTPFactor(u, "testSimpleName")
|
||||||
|
require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified))
|
||||||
|
require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey))
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/users/%s/factors/%s/", u.ID, f.ID), nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
_, err = models.FindFactorByFactorID(ts.API.db, f.ID)
|
||||||
|
require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminUserGetFactor tests API /admin/user/<user_id>/factors/
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserGetFactors() {
|
||||||
|
u, err := models.NewUser("123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
f := models.NewTOTPFactor(u, "testSimpleName")
|
||||||
|
require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey))
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/admin/users/%s/factors/", u.ID), nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
getFactorsResp := []*models.Factor{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getFactorsResp))
|
||||||
|
require.Equal(ts.T(), getFactorsResp[0].Secret, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserUpdateFactor() {
|
||||||
|
u, err := models.NewUser("123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
f := models.NewPhoneFactor(u, "123456789", "testSimpleName")
|
||||||
|
require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey))
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
|
||||||
|
|
||||||
|
var cases = []struct {
|
||||||
|
Desc string
|
||||||
|
FactorData map[string]interface{}
|
||||||
|
ExpectedCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Desc: "Update Factor friendly name",
|
||||||
|
FactorData: map[string]interface{}{
|
||||||
|
"friendly_name": "john",
|
||||||
|
},
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Update Factor phone number",
|
||||||
|
FactorData: map[string]interface{}{
|
||||||
|
"phone": "+1976154321",
|
||||||
|
},
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize factor data
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.Desc, func() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.FactorData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s/factors/%s/", u.ID, f.ID), &buffer)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), c.ExpectedCode, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
params map[string]interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "create user without email and phone",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"password": "test_password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "create user with password and password hash",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "test_password",
|
||||||
|
"password_hash": "$2y$10$Tk6yEdmTbb/eQ/haDMaCsuCsmtPVprjHMcij1RqiJdLGPDXnL3L1a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "invalid ban duration",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"ban_duration": "never",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "custom id is nil",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "bad id format",
|
||||||
|
params: map[string]interface{}{
|
||||||
|
"id": "bad_uuid_format",
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusBadRequest, w.Code, w)
|
||||||
|
|
||||||
|
data := map[string]interface{}{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.Equal(ts.T(), data["error_code"], ErrorCodeValidationFailed)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/supabase/auth/internal/metering"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) SignupAnonymously(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
config := a.config
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
aud := a.requestAud(ctx, r)
|
||||||
|
|
||||||
|
if config.DisableSignup {
|
||||||
|
return unprocessableEntityError(ErrorCodeSignupDisabled, "Signups not allowed for this instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &SignupParams{}
|
||||||
|
if err := retrieveRequestParams(r, params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
params.Aud = aud
|
||||||
|
params.Provider = "anonymous"
|
||||||
|
|
||||||
|
newUser, err := params.ToUserModel(false /* <- isSSOUser */)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var grantParams models.GrantParams
|
||||||
|
grantParams.FillGrantParams(r)
|
||||||
|
|
||||||
|
var token *AccessTokenResponse
|
||||||
|
err = db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
var terr error
|
||||||
|
newUser, terr = a.signupNewUser(tx, newUser)
|
||||||
|
if terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
token, terr = a.issueRefreshToken(r, tx, newUser, models.Anonymous, grantParams)
|
||||||
|
if terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Database error creating anonymous user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metering.RecordLogin("anonymous", newUser.ID)
|
||||||
|
return sendJSON(w, http.StatusOK, token)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
mail "github.com/supabase/auth/internal/mailer"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnonymousTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnonymous(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &AnonymousTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AnonymousTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
|
||||||
|
// Create anonymous user
|
||||||
|
params := &SignupParams{
|
||||||
|
Aud: ts.Config.JWT.Aud,
|
||||||
|
Provider: "anonymous",
|
||||||
|
}
|
||||||
|
u, err := params.ToUserModel(false)
|
||||||
|
require.NoError(ts.T(), err, "Error creating test user model")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new anonymous test user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AnonymousTestSuite) TestAnonymousLogins() {
|
||||||
|
ts.Config.External.AnonymousUsers.Enabled = true
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"field": "foo",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/signup", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := &AccessTokenResponse{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
assert.NotEmpty(ts.T(), data.User.ID)
|
||||||
|
assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud)
|
||||||
|
assert.Empty(ts.T(), data.User.GetEmail())
|
||||||
|
assert.Empty(ts.T(), data.User.GetPhone())
|
||||||
|
assert.True(ts.T(), data.User.IsAnonymous)
|
||||||
|
assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"field": "foo"}), data.User.UserMetaData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() {
|
||||||
|
ts.Config.External.AnonymousUsers.Enabled = true
|
||||||
|
ts.Config.Sms.TestOTP = map[string]string{"1234567890": "000000", "1234560000": "000000"}
|
||||||
|
// test OTPs still require setting up an sms provider
|
||||||
|
ts.Config.Sms.Provider = "twilio"
|
||||||
|
ts.Config.Sms.Twilio.AccountSid = "fake-sid"
|
||||||
|
ts.Config.Sms.Twilio.AuthToken = "fake-token"
|
||||||
|
ts.Config.Sms.Twilio.MessageServiceSid = "fake-message-service-sid"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
body map[string]interface{}
|
||||||
|
verificationType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "convert anonymous user to permanent user with email",
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
},
|
||||||
|
verificationType: "email_change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "convert anonymous user to permanent user with phone",
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"phone": "1234567890",
|
||||||
|
},
|
||||||
|
verificationType: "phone_change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "convert anonymous user to permanent user with email & password",
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"email": "test2@example.com",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
verificationType: "email_change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "convert anonymous user to permanent user with phone & password",
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"phone": "1234560000",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
verificationType: "phone_change",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/signup", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
signupResponse := &AccessTokenResponse{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&signupResponse))
|
||||||
|
|
||||||
|
// Add email to anonymous user
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPut, "/user", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signupResponse.Token))
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Check if anonymous user is still anonymous
|
||||||
|
user, err := models.FindUserByID(ts.API.db, signupResponse.User.ID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), user)
|
||||||
|
require.True(ts.T(), user.IsAnonymous)
|
||||||
|
|
||||||
|
// Check if user has a password set
|
||||||
|
if c.body["password"] != nil {
|
||||||
|
require.True(ts.T(), user.HasPassword())
|
||||||
|
}
|
||||||
|
|
||||||
|
switch c.verificationType {
|
||||||
|
case mail.EmailChangeVerification:
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"token_hash": user.EmailChangeTokenNew,
|
||||||
|
"type": c.verificationType,
|
||||||
|
}))
|
||||||
|
case phoneChangeVerification:
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"phone": user.PhoneChange,
|
||||||
|
"token": "000000",
|
||||||
|
"type": c.verificationType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/verify", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
data := &AccessTokenResponse{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
// User is a permanent user and not anonymous anymore
|
||||||
|
assert.Equal(ts.T(), signupResponse.User.ID, data.User.ID)
|
||||||
|
assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud)
|
||||||
|
assert.False(ts.T(), data.User.IsAnonymous)
|
||||||
|
|
||||||
|
// User should have an identity
|
||||||
|
assert.Len(ts.T(), data.User.Identities, 1)
|
||||||
|
|
||||||
|
switch c.verificationType {
|
||||||
|
case mail.EmailChangeVerification:
|
||||||
|
assert.Equal(ts.T(), c.body["email"], data.User.GetEmail())
|
||||||
|
assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "email", "providers": []interface{}{"email"}}), data.User.AppMetaData)
|
||||||
|
assert.NotEmpty(ts.T(), data.User.EmailConfirmedAt)
|
||||||
|
case phoneChangeVerification:
|
||||||
|
assert.Equal(ts.T(), c.body["phone"], data.User.GetPhone())
|
||||||
|
assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "phone", "providers": []interface{}{"phone"}}), data.User.AppMetaData)
|
||||||
|
assert.NotEmpty(ts.T(), data.User.PhoneConfirmedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AnonymousTestSuite) TestRateLimitAnonymousSignups() {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
ts.Config.External.AnonymousUsers.Enabled = true
|
||||||
|
|
||||||
|
// It rate limits after 30 requests
|
||||||
|
for i := 0; i < int(ts.Config.RateLimitAnonymousUsers); i++ {
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("My-Custom-Header", "1.2.3.4")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("My-Custom-Header", "1.2.3.4")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusTooManyRequests, w.Code)
|
||||||
|
|
||||||
|
// It ignores X-Forwarded-For by default
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
|
||||||
|
req.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusTooManyRequests, w.Code)
|
||||||
|
|
||||||
|
// It doesn't rate limit a new value for the limited header
|
||||||
|
req.Header.Set("My-Custom-Header", "5.6.7.8")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AnonymousTestSuite) TestAdminUpdateAnonymousUser() {
|
||||||
|
claims := &AccessTokenClaims{
|
||||||
|
Role: "supabase_admin",
|
||||||
|
}
|
||||||
|
adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
u1, err := models.NewUser("", "", "", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
u1.IsAnonymous = true
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u1))
|
||||||
|
|
||||||
|
u2, err := models.NewUser("", "", "", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
u2.IsAnonymous = true
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u2))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
userId uuid.UUID
|
||||||
|
body map[string]interface{}
|
||||||
|
expected map[string]interface{}
|
||||||
|
expectedIdentities int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "update anonymous user with email and email confirm true",
|
||||||
|
userId: u1.ID,
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"email": "foo@example.com",
|
||||||
|
"email_confirm": true,
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "foo@example.com",
|
||||||
|
"is_anonymous": false,
|
||||||
|
},
|
||||||
|
expectedIdentities: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "update anonymous user with email and email confirm false",
|
||||||
|
userId: u2.ID,
|
||||||
|
body: map[string]interface{}{
|
||||||
|
"email": "bar@example.com",
|
||||||
|
"email_confirm": false,
|
||||||
|
},
|
||||||
|
expected: map[string]interface{}{
|
||||||
|
"email": "bar@example.com",
|
||||||
|
"is_anonymous": true,
|
||||||
|
},
|
||||||
|
expectedIdentities: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", c.userId), &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminJwt))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var data models.User
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
|
||||||
|
require.NotNil(ts.T(), data)
|
||||||
|
require.Len(ts.T(), data.Identities, c.expectedIdentities)
|
||||||
|
|
||||||
|
actual := map[string]interface{}{
|
||||||
|
"email": data.GetEmail(),
|
||||||
|
"is_anonymous": data.IsAnonymous,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(ts.T(), c.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/cors"
|
||||||
|
"github.com/sebest/xff"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/mailer"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
"github.com/supabase/hibp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
audHeaderName = "X-JWT-AUD"
|
||||||
|
defaultVersion = "unknown version"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bearerRegexp = regexp.MustCompile(`^(?:B|b)earer (\S+$)`)
|
||||||
|
|
||||||
|
// API is the main REST API
|
||||||
|
type API struct {
|
||||||
|
handler http.Handler
|
||||||
|
db *storage.Connection
|
||||||
|
config *conf.GlobalConfiguration
|
||||||
|
version string
|
||||||
|
|
||||||
|
hibpClient *hibp.PwnedClient
|
||||||
|
|
||||||
|
// overrideTime can be used to override the clock used by handlers. Should only be used in tests!
|
||||||
|
overrideTime func() time.Time
|
||||||
|
|
||||||
|
limiterOpts *LimiterOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Now() time.Time {
|
||||||
|
if a.overrideTime != nil {
|
||||||
|
return a.overrideTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPI instantiates a new REST API
|
||||||
|
func NewAPI(globalConfig *conf.GlobalConfiguration, db *storage.Connection, opt ...Option) *API {
|
||||||
|
return NewAPIWithVersion(globalConfig, db, defaultVersion, opt...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deprecationNotices() {
|
||||||
|
config := a.config
|
||||||
|
|
||||||
|
log := logrus.WithField("component", "api")
|
||||||
|
|
||||||
|
if config.JWT.AdminGroupName != "" {
|
||||||
|
log.Warn("DEPRECATION NOTICE: GOTRUE_JWT_ADMIN_GROUP_NAME not supported by Supabase's GoTrue, will be removed soon")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.JWT.DefaultGroupName != "" {
|
||||||
|
log.Warn("DEPRECATION NOTICE: GOTRUE_JWT_DEFAULT_GROUP_NAME not supported by Supabase's GoTrue, will be removed soon")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIWithVersion creates a new REST API using the specified version
|
||||||
|
func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Connection, version string, opt ...Option) *API {
|
||||||
|
api := &API{config: globalConfig, db: db, version: version}
|
||||||
|
|
||||||
|
for _, o := range opt {
|
||||||
|
o.apply(api)
|
||||||
|
}
|
||||||
|
if api.limiterOpts == nil {
|
||||||
|
api.limiterOpts = NewLimiterOptions(globalConfig)
|
||||||
|
}
|
||||||
|
if api.config.Password.HIBP.Enabled {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
// all HIBP API requests should finish quickly to avoid
|
||||||
|
// unnecessary slowdowns
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
api.hibpClient = &hibp.PwnedClient{
|
||||||
|
UserAgent: api.config.Password.HIBP.UserAgent,
|
||||||
|
HTTP: httpClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
if api.config.Password.HIBP.Bloom.Enabled {
|
||||||
|
cache := utilities.NewHIBPBloomCache(api.config.Password.HIBP.Bloom.Items, api.config.Password.HIBP.Bloom.FalsePositives)
|
||||||
|
api.hibpClient.Cache = cache
|
||||||
|
|
||||||
|
logrus.Infof("Pwned passwords cache is %.2f KB", float64(cache.Cap())/(8*1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.deprecationNotices()
|
||||||
|
|
||||||
|
xffmw, _ := xff.Default()
|
||||||
|
logger := observability.NewStructuredLogger(logrus.StandardLogger(), globalConfig)
|
||||||
|
|
||||||
|
r := newRouter()
|
||||||
|
r.UseBypass(observability.AddRequestID(globalConfig))
|
||||||
|
r.UseBypass(logger)
|
||||||
|
r.UseBypass(xffmw.Handler)
|
||||||
|
r.UseBypass(recoverer)
|
||||||
|
|
||||||
|
if globalConfig.API.MaxRequestDuration > 0 {
|
||||||
|
r.UseBypass(timeoutMiddleware(globalConfig.API.MaxRequestDuration))
|
||||||
|
}
|
||||||
|
|
||||||
|
// request tracing should be added only when tracing or metrics is enabled
|
||||||
|
if globalConfig.Tracing.Enabled || globalConfig.Metrics.Enabled {
|
||||||
|
r.UseBypass(observability.RequestTracing())
|
||||||
|
}
|
||||||
|
|
||||||
|
if globalConfig.DB.CleanupEnabled {
|
||||||
|
cleanup := models.NewCleanup(globalConfig)
|
||||||
|
r.UseBypass(api.databaseCleanup(cleanup))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Get("/health", api.HealthCheck)
|
||||||
|
r.Get("/.well-known/jwks.json", api.Jwks)
|
||||||
|
|
||||||
|
r.Route("/callback", func(r *router) {
|
||||||
|
r.Use(api.isValidExternalHost)
|
||||||
|
r.Use(api.loadFlowState)
|
||||||
|
|
||||||
|
r.Get("/", api.ExternalProviderCallback)
|
||||||
|
r.Post("/", api.ExternalProviderCallback)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/", func(r *router) {
|
||||||
|
r.Use(api.isValidExternalHost)
|
||||||
|
|
||||||
|
r.Get("/settings", api.Settings)
|
||||||
|
|
||||||
|
r.Get("/authorize", api.ExternalProviderRedirect)
|
||||||
|
|
||||||
|
r.With(api.requireAdminCredentials).Post("/invite", api.Invite)
|
||||||
|
r.With(api.verifyCaptcha).Route("/signup", func(r *router) {
|
||||||
|
// rate limit per hour
|
||||||
|
limitAnonymousSignIns := api.limiterOpts.AnonymousSignIns
|
||||||
|
limitSignups := api.limiterOpts.Signups
|
||||||
|
r.Post("/", func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
params := &SignupParams{}
|
||||||
|
if err := retrieveRequestParams(r, params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if params.Email == "" && params.Phone == "" {
|
||||||
|
if !api.config.External.AnonymousUsers.Enabled {
|
||||||
|
return unprocessableEntityError(ErrorCodeAnonymousProviderDisabled, "Anonymous sign-ins are disabled")
|
||||||
|
}
|
||||||
|
if _, err := api.limitHandler(limitAnonymousSignIns)(w, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return api.SignupAnonymously(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply ip-based rate limiting on otps
|
||||||
|
if _, err := api.limitHandler(limitSignups)(w, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return api.Signup(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.Recover)).
|
||||||
|
With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.Resend)).
|
||||||
|
With(api.verifyCaptcha).Post("/resend", api.Resend)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.MagicLink)).
|
||||||
|
With(api.verifyCaptcha).Post("/magiclink", api.MagicLink)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.Otp)).
|
||||||
|
With(api.verifyCaptcha).Post("/otp", api.Otp)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.Token)).
|
||||||
|
With(api.verifyCaptcha).Post("/token", api.Token)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.Verify)).Route("/verify", func(r *router) {
|
||||||
|
r.Get("/", api.Verify)
|
||||||
|
r.Post("/", api.Verify)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(api.requireAuthentication).Post("/logout", api.Logout)
|
||||||
|
|
||||||
|
r.With(api.requireAuthentication).Route("/reauthenticate", func(r *router) {
|
||||||
|
r.Get("/", api.Reauthenticate)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(api.requireAuthentication).Route("/user", func(r *router) {
|
||||||
|
r.Get("/", api.UserGet)
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.User)).Put("/", api.UserUpdate)
|
||||||
|
|
||||||
|
r.Route("/identities", func(r *router) {
|
||||||
|
r.Use(api.requireManualLinkingEnabled)
|
||||||
|
r.Get("/authorize", api.LinkIdentity)
|
||||||
|
r.Delete("/{identity_id}", api.DeleteIdentity)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(api.requireAuthentication).Route("/factors", func(r *router) {
|
||||||
|
r.Use(api.requireNotAnonymous)
|
||||||
|
r.Post("/", api.EnrollFactor)
|
||||||
|
r.Route("/{factor_id}", func(r *router) {
|
||||||
|
r.Use(api.loadFactor)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.FactorVerify)).
|
||||||
|
Post("/verify", api.VerifyFactor)
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.FactorChallenge)).
|
||||||
|
Post("/challenge", api.ChallengeFactor)
|
||||||
|
r.Delete("/", api.UnenrollFactor)
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/sso", func(r *router) {
|
||||||
|
r.Use(api.requireSAMLEnabled)
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.SSO)).
|
||||||
|
With(api.verifyCaptcha).Post("/", api.SingleSignOn)
|
||||||
|
|
||||||
|
r.Route("/saml", func(r *router) {
|
||||||
|
r.Get("/metadata", api.SAMLMetadata)
|
||||||
|
|
||||||
|
r.With(api.limitHandler(api.limiterOpts.SAMLAssertion)).
|
||||||
|
Post("/acs", api.SamlAcs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/admin", func(r *router) {
|
||||||
|
r.Use(api.requireAdminCredentials)
|
||||||
|
|
||||||
|
r.Route("/audit", func(r *router) {
|
||||||
|
r.Get("/", api.adminAuditLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/users", func(r *router) {
|
||||||
|
r.Get("/", api.adminUsers)
|
||||||
|
r.Post("/", api.adminUserCreate)
|
||||||
|
|
||||||
|
r.Route("/{user_id}", func(r *router) {
|
||||||
|
r.Use(api.loadUser)
|
||||||
|
r.Route("/factors", func(r *router) {
|
||||||
|
r.Get("/", api.adminUserGetFactors)
|
||||||
|
r.Route("/{factor_id}", func(r *router) {
|
||||||
|
r.Use(api.loadFactor)
|
||||||
|
r.Delete("/", api.adminUserDeleteFactor)
|
||||||
|
r.Put("/", api.adminUserUpdateFactor)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/", api.adminUserGet)
|
||||||
|
r.Put("/", api.adminUserUpdate)
|
||||||
|
r.Delete("/", api.adminUserDelete)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Post("/generate_link", api.adminGenerateLink)
|
||||||
|
|
||||||
|
r.Route("/sso", func(r *router) {
|
||||||
|
r.Route("/providers", func(r *router) {
|
||||||
|
r.Get("/", api.adminSSOProvidersList)
|
||||||
|
r.Post("/", api.adminSSOProvidersCreate)
|
||||||
|
|
||||||
|
r.Route("/{idp_id}", func(r *router) {
|
||||||
|
r.Use(api.loadSSOProvider)
|
||||||
|
|
||||||
|
r.Get("/", api.adminSSOProvidersGet)
|
||||||
|
r.Put("/", api.adminSSOProvidersUpdate)
|
||||||
|
r.Delete("/", api.adminSSOProvidersDelete)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
corsHandler := cors.New(cors.Options{
|
||||||
|
AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
||||||
|
AllowedHeaders: globalConfig.CORS.AllAllowedHeaders([]string{"Accept", "Authorization", "Content-Type", "X-Client-IP", "X-Client-Info", audHeaderName, useCookieHeader, APIVersionHeaderName}),
|
||||||
|
ExposedHeaders: []string{"X-Total-Count", "Link", APIVersionHeaderName},
|
||||||
|
AllowCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
api.handler = corsHandler.Handler(r)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthCheckResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck endpoint indicates if the gotrue api service is available
|
||||||
|
func (a *API) HealthCheck(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return sendJSON(w, http.StatusOK, HealthCheckResponse{
|
||||||
|
Version: a.version,
|
||||||
|
Name: "GoTrue",
|
||||||
|
Description: "GoTrue is a user registration and authentication API",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailer returns NewMailer with the current tenant config
|
||||||
|
func (a *API) Mailer() mailer.Mailer {
|
||||||
|
config := a.config
|
||||||
|
return mailer.NewMailer(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements the http.Handler interface by passing the request along
|
||||||
|
// to its underlying Handler.
|
||||||
|
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/crypto"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
"github.com/supabase/auth/internal/storage/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiTestVersion = "1"
|
||||||
|
apiTestConfig = "../../hack/test.env"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
crypto.PasswordHashCost = crypto.QuickHashCost
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAPIForTest creates a new API to run tests with.
|
||||||
|
// Using this function allows us to keep track of the database connection
|
||||||
|
// and cleaning up data between tests.
|
||||||
|
func setupAPIForTest() (*API, *conf.GlobalConfiguration, error) {
|
||||||
|
return setupAPIForTestWithCallback(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAPIForTestWithCallback(cb func(*conf.GlobalConfiguration, *storage.Connection)) (*API, *conf.GlobalConfiguration, error) {
|
||||||
|
config, err := conf.LoadGlobal(apiTestConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cb != nil {
|
||||||
|
cb(config, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := test.SetupDBConnection(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cb != nil {
|
||||||
|
cb(nil, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiterOpts := NewLimiterOptions(config)
|
||||||
|
return NewAPIWithVersion(config, conn, apiTestVersion, limiterOpts), config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailEnabledByDefault(t *testing.T) {
|
||||||
|
api, _, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, api.config.External.Email.Enabled)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const APIVersionHeaderName = "X-Supabase-Api-Version"
|
||||||
|
|
||||||
|
type APIVersion = time.Time
|
||||||
|
|
||||||
|
var (
|
||||||
|
APIVersionInitial = time.Time{}
|
||||||
|
APIVersion20240101 = time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
func DetermineClosestAPIVersion(date string) (APIVersion, error) {
|
||||||
|
if date == "" {
|
||||||
|
return APIVersionInitial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseInLocation("2006-01-02", date, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
return APIVersionInitial, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Compare(APIVersion20240101) >= 0 {
|
||||||
|
return APIVersion20240101, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return APIVersionInitial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatAPIVersion(apiVersion APIVersion) string {
|
||||||
|
return apiVersion.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetermineClosestAPIVersion(t *testing.T) {
|
||||||
|
version, err := DetermineClosestAPIVersion("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, APIVersionInitial, version)
|
||||||
|
|
||||||
|
version, err = DetermineClosestAPIVersion("Not a date")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, APIVersionInitial, version)
|
||||||
|
|
||||||
|
version, err = DetermineClosestAPIVersion("2023-12-31")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, APIVersionInitial, version)
|
||||||
|
|
||||||
|
version, err = DetermineClosestAPIVersion("2024-01-01")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, APIVersion20240101, version)
|
||||||
|
|
||||||
|
version, err = DetermineClosestAPIVersion("2024-01-02")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, APIVersion20240101, version)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var filterColumnMap = map[string][]string{
|
||||||
|
"author": {"actor_username", "actor_name"},
|
||||||
|
"action": {"action"},
|
||||||
|
"type": {"log_type"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminAuditLog(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
|
||||||
|
// aud := a.requestAud(ctx, r)
|
||||||
|
pageParams, err := paginate(r)
|
||||||
|
if err != nil {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Bad Pagination Parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var col []string
|
||||||
|
var qval string
|
||||||
|
q := r.URL.Query().Get("query")
|
||||||
|
if q != "" {
|
||||||
|
var exists bool
|
||||||
|
qparts := strings.SplitN(q, ":", 2)
|
||||||
|
col, exists = filterColumnMap[qparts[0]]
|
||||||
|
if !exists || len(qparts) < 2 {
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, "Invalid query scope: %s", q)
|
||||||
|
}
|
||||||
|
qval = qparts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := models.FindAuditLogEntries(db, col, qval, pageParams)
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Error searching for audit logs").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addPaginationHeaders(w, r, pageParams)
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, logs)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudit(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &AuditTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuditTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
ts.token = ts.makeSuperAdmin("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuditTestSuite) makeSuperAdmin(email string) string {
|
||||||
|
u, err := models.NewUser("", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"})
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
|
||||||
|
u.Role = "supabase_admin"
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u))
|
||||||
|
|
||||||
|
session, err := models.NewSession(u.ID, nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(session))
|
||||||
|
|
||||||
|
var token string
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil)
|
||||||
|
token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.PasswordGrant)
|
||||||
|
require.NoError(ts.T(), err, "Error generating access token")
|
||||||
|
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err, "Error parsing token")
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuditTestSuite) TestAuditGet() {
|
||||||
|
ts.prepareDeleteEvent()
|
||||||
|
// CHECK FOR AUDIT LOG
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/audit", nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
assert.Equal(ts.T(), "</admin/audit?page=1>; rel=\"last\"", w.Header().Get("Link"))
|
||||||
|
assert.Equal(ts.T(), "1", w.Header().Get("X-Total-Count"))
|
||||||
|
|
||||||
|
logs := []models.AuditLogEntry{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&logs))
|
||||||
|
|
||||||
|
require.Len(ts.T(), logs, 1)
|
||||||
|
require.Contains(ts.T(), logs[0].Payload, "actor_username")
|
||||||
|
assert.Equal(ts.T(), "supabase_admin", logs[0].Payload["actor_username"])
|
||||||
|
traits, ok := logs[0].Payload["traits"].(map[string]interface{})
|
||||||
|
require.True(ts.T(), ok)
|
||||||
|
require.Contains(ts.T(), traits, "user_email")
|
||||||
|
assert.Equal(ts.T(), "test-delete@example.com", traits["user_email"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuditTestSuite) TestAuditFilters() {
|
||||||
|
ts.prepareDeleteEvent()
|
||||||
|
|
||||||
|
queries := []string{
|
||||||
|
"/admin/audit?query=action:user_deleted",
|
||||||
|
"/admin/audit?query=type:team",
|
||||||
|
"/admin/audit?query=author:admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range queries {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, q, nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
logs := []models.AuditLogEntry{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&logs))
|
||||||
|
|
||||||
|
require.Len(ts.T(), logs, 1)
|
||||||
|
require.Contains(ts.T(), logs[0].Payload, "actor_username")
|
||||||
|
assert.Equal(ts.T(), "supabase_admin", logs[0].Payload["actor_username"])
|
||||||
|
traits, ok := logs[0].Payload["traits"].(map[string]interface{})
|
||||||
|
require.True(ts.T(), ok)
|
||||||
|
require.Contains(ts.T(), traits, "user_email")
|
||||||
|
assert.Equal(ts.T(), "test-delete@example.com", traits["user_email"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuditTestSuite) prepareDeleteEvent() {
|
||||||
|
// DELETE USER
|
||||||
|
u, err := models.NewUser("12345678", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/users/%s", u.ID), nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireAuthentication checks incoming requests for tokens presented using the Authorization header
|
||||||
|
func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
|
token, err := a.extractBearerToken(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, err := a.parseJWTClaims(token, r)
|
||||||
|
if err != nil {
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, err = a.maybeLoadUserOrSession(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) requireNotAnonymous(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims := getClaims(ctx)
|
||||||
|
if claims.IsAnonymous {
|
||||||
|
return nil, forbiddenError(ErrorCodeNoAuthorization, "Anonymous user not allowed to perform these actions")
|
||||||
|
}
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) requireAdmin(ctx context.Context) (context.Context, error) {
|
||||||
|
// Find the administrative user
|
||||||
|
claims := getClaims(ctx)
|
||||||
|
if claims == nil {
|
||||||
|
return nil, forbiddenError(ErrorCodeBadJWT, "Invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminRoles := a.config.JWT.AdminRoles
|
||||||
|
|
||||||
|
if isStringInSlice(claims.Role, adminRoles) {
|
||||||
|
// successful authentication
|
||||||
|
return withAdminUser(ctx, &models.User{Role: claims.Role, Email: storage.NullString(claims.Role)}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, forbiddenError(ErrorCodeNotAdmin, "User not allowed").WithInternalMessage(fmt.Sprintf("this token needs to have one of the following roles: %v", strings.Join(adminRoles, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) extractBearerToken(r *http.Request) (string, error) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
matches := bearerRegexp.FindStringSubmatch(authHeader)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return "", httpError(http.StatusUnauthorized, ErrorCodeNoAuthorization, "This endpoint requires a Bearer token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) parseJWTClaims(bearer string, r *http.Request) (context.Context, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
config := a.config
|
||||||
|
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods(config.JWT.ValidMethods))
|
||||||
|
token, err := p.ParseWithClaims(bearer, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if kid, ok := token.Header["kid"]; ok {
|
||||||
|
if kidStr, ok := kid.(string); ok {
|
||||||
|
return conf.FindPublicKeyByKid(kidStr, &config.JWT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alg, ok := token.Header["alg"]; ok {
|
||||||
|
if alg == jwt.SigningMethodHS256.Name {
|
||||||
|
// preserve backward compatibility for cases where the kid is not set
|
||||||
|
return []byte(config.JWT.Secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("missing kid")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, forbiddenError(ErrorCodeBadJWT, "invalid JWT: unable to parse or verify signature, %v", err).WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return withToken(ctx, token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) maybeLoadUserOrSession(ctx context.Context) (context.Context, error) {
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
claims := getClaims(ctx)
|
||||||
|
|
||||||
|
if claims == nil {
|
||||||
|
return ctx, forbiddenError(ErrorCodeBadJWT, "invalid token: missing claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Subject == "" {
|
||||||
|
return nil, forbiddenError(ErrorCodeBadJWT, "invalid claim: missing sub claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *models.User
|
||||||
|
if claims.Subject != "" {
|
||||||
|
userId, err := uuid.FromString(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return ctx, badRequestError(ErrorCodeBadJWT, "invalid claim: sub claim must be a UUID").WithInternalError(err)
|
||||||
|
}
|
||||||
|
user, err = models.FindUserByID(db, userId)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return ctx, forbiddenError(ErrorCodeUserNotFound, "User from sub claim in JWT does not exist")
|
||||||
|
}
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
ctx = withUser(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
var session *models.Session
|
||||||
|
if claims.SessionId != "" && claims.SessionId != uuid.Nil.String() {
|
||||||
|
sessionId, err := uuid.FromString(claims.SessionId)
|
||||||
|
if err != nil {
|
||||||
|
return ctx, forbiddenError(ErrorCodeBadJWT, "invalid claim: session_id claim must be a UUID").WithInternalError(err)
|
||||||
|
}
|
||||||
|
session, err = models.FindSessionByID(db, sessionId, false)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return ctx, forbiddenError(ErrorCodeSessionNotFound, "Session from session_id claim in JWT does not exist").WithInternalError(err).WithInternalMessage(fmt.Sprintf("session id (%s) doesn't exist", sessionId))
|
||||||
|
}
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
ctx = withSession(ctx, session)
|
||||||
|
}
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
jwk "github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &AuthTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuthTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
u, err := models.NewUser("", "test@example.com", "password", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error creating test user model")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuthTestSuite) TestExtractBearerToken() {
|
||||||
|
userClaims := &AccessTokenClaims{
|
||||||
|
Role: "authenticated",
|
||||||
|
}
|
||||||
|
userJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaims).SignedString([]byte(ts.Config.JWT.Secret))
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+userJwt)
|
||||||
|
|
||||||
|
token, err := ts.API.extractBearerToken(req)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Equal(ts.T(), userJwt, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuthTestSuite) TestParseJWTClaims() {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
key map[string]interface{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "HMAC key",
|
||||||
|
key: map[string]interface{}{
|
||||||
|
"kty": "oct",
|
||||||
|
"k": "S1LgKUjeqXDEolv9WPtjUpADVMHU_KYu8uRDrM-pDGg",
|
||||||
|
"kid": "ac50c3cc-9cf7-4fd6-a11f-fe066fd39118",
|
||||||
|
"key_ops": []string{"sign", "verify"},
|
||||||
|
"alg": "HS256",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "RSA key",
|
||||||
|
key: map[string]interface{}{
|
||||||
|
"kty": "RSA",
|
||||||
|
"n": "2g0B_hMIx5ZPuTUtLRpRr0k314XniYm3AUFgR5FmTZIjrn7vLwsWij-2egGZeHa-y9ypAgB9Q-lQ3AlT7RMPiCIyLQI6TTC8k10NEnj8c0QZwENx1Qr8aBbuZbOP9Cz30EMWZSbzMbz7r8-3rp5wBRBtIPnLlbfZh_p0iBaJfB77-r_mvhOIFM4xS7ef3nkE96dnvbEN5a-HfjzDJIAt-LniUvzMWW2gQcmHiM4oeijE3PHesapLMt2JpsMhSRo8L7tysags9VMoyZ1GnpCdjtRwb_KpY9QTjV6lL8G5nsKFH7bhABYcpjDOvqkfT5nPXj6C7oCo6MPRirPWUTbq2w",
|
||||||
|
"e": "AQAB",
|
||||||
|
"d": "OOTj_DNjOxCRRLYHT5lqbt4f3_BkdZKlWYKBaKsbkmnrPYCJUDEIdJIjPrpkHPZ-2hp9TrRp-upJ2t_kMhujFdY2WWAXbkSlL5475vICjODcBzqR3RC8wzwYgBjWGtQQ5RpcIZCELBovYbRFLR7SA8BBeTU0VaBe9gf3l_qpbOT9QIl268uFdWndTjpehGLQRmAtR1snhvTha0b9nsBZsM_K-EfnoF7Q_lPsjwWDvIGpFXao8Ifaa_sFtQkHjHVBMW2Qgx3ZSrEva_brk7w0MNSYI7Nsmr56xFOpFRwZy0v8ZtgQZ4hXmUInRHIoQ2APeds9YmemojvJKVflt9pLIQ",
|
||||||
|
"p": "-o2hdQ5Z35cIS5APTVULj_BMoPJpgkuX-PSYC1SeBeff9K04kG5zrFMWJy_-27-ys4q754lpNwJdX2CjN1nb6qyn-uKP8B2oLayKs9ebkiOqvm3S2Xblvi_F8x6sOLba3lTYHK8G7U9aMB9U0mhAzzMFdw15XXusVFDvk-zxL28",
|
||||||
|
"q": "3sp-7HzZE_elKRmebjivcDhkXO2GrcN3EIqYbbXssHZFXJwVE9oc2CErGWa7QetOCr9C--ZuTmX0X3L--CoYr-hMB0dN8lcAhapr3aau-4i7vE3DWSUdcFSyi0BBDg8pWQWbxNyTXBuWeh1cnRBsLjCxAOVTF0y3_BnVR7mbBVU",
|
||||||
|
"dp": "DuYHGMfOrk3zz1J0pnuNIXT_iX6AqZ_HHKWmuN3CO8Wq-oimWWhH9pJGOfRPqk9-19BDFiSEniHE3ZwIeI0eV5kGsBNyzatlybl90e3bMVhvmb08EXRRevqqQaesQ_8Tiq7u3t3Fgqz6RuxGBfDvEaMOCyNA-T8WYzkg1eH8AX8",
|
||||||
|
"dq": "opOCK3CvuDJvA57-TdBvtaRxGJ78OLD6oceBlA29useTthDwEJyJj-4kVVTyMRhUyuLnLoro06zytvRjuxR9D2CkmmseJkn2x5OlQwnvhv4wgSj99H9xDBfCcntg_bFyqtO859tObVh0ZogmnTbuuoYtpEm0aLxDRmRTjxOSXEE",
|
||||||
|
"qi": "8skVE7BDASHXytKSWYbkxD0B3WpXic2rtnLgiMgasdSxul8XwcB-vjVSZprVrxkcmm6ZhszoxOlq8yylBmMvAnG_gEzTls_xapeuEXGYiGaTcpkCt1r-tBKcQkka2SayaWwAljsX4xSw-zKP2koUkEET_tIcbBOW1R4OWfRGqOI",
|
||||||
|
"kid": "0d24b26c-b3ec-4c02-acfd-d5a54d50b3a4",
|
||||||
|
"key_ops": []string{"sign", "verify"},
|
||||||
|
"alg": "RS256",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "EC key",
|
||||||
|
key: map[string]interface{}{
|
||||||
|
"kty": "EC",
|
||||||
|
"x": "5wsOh-DrNPpm9KkuydtgGs_cv3oNvtR9OdXywt12aS4",
|
||||||
|
"y": "0y01ZbuH_VQjMEd8fcYaLdiv25EVJ5GOrb79dJJsqrM",
|
||||||
|
"crv": "P-256",
|
||||||
|
"d": "EDP4ReMMpAUcf82EF3JYvkm8C5hVAh258Rj6f3HTx7c",
|
||||||
|
"kid": "10646a77-f470-44a8-8400-2f988d9c9c1a",
|
||||||
|
"key_ops": []string{"sign", "verify"},
|
||||||
|
"alg": "ES256",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Ed25519 key",
|
||||||
|
key: map[string]interface{}{
|
||||||
|
"crv": "Ed25519",
|
||||||
|
"d": "jVpCLvOxatVkKe1MW9nFRn6Q8VVZPq5yziKU_Z0Yu-c",
|
||||||
|
"x": "YDkGdufJBQEPO6ylvd9IKfZlzvm9tOG5VCDpkJSSkiA",
|
||||||
|
"kty": "OKP",
|
||||||
|
"kid": "ec5e7a96-ea66-456c-826c-d8d6cb928c0f",
|
||||||
|
"key_ops": []string{"sign", "verify"},
|
||||||
|
"alg": "EdDSA",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
bytes, err := json.Marshal(c.key)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
privKey, err := jwk.ParseKey(bytes)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
pubKey, err := privKey.PublicKey()
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
ts.Config.JWT.Keys = conf.JwtKeysDecoder{privKey.KeyID(): conf.JwkInfo{
|
||||||
|
PublicKey: pubKey,
|
||||||
|
PrivateKey: privKey,
|
||||||
|
}}
|
||||||
|
ts.Config.JWT.ValidMethods = nil
|
||||||
|
require.NoError(ts.T(), ts.Config.ApplyDefaults())
|
||||||
|
|
||||||
|
userClaims := &AccessTokenClaims{
|
||||||
|
Role: "authenticated",
|
||||||
|
}
|
||||||
|
|
||||||
|
// get signing key and method from config
|
||||||
|
jwk, err := conf.GetSigningJwk(&ts.Config.JWT)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
signingMethod := conf.GetSigningAlg(jwk)
|
||||||
|
signingKey, err := conf.GetSigningKey(jwk)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
userJwtToken := jwt.NewWithClaims(signingMethod, userClaims)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
userJwtToken.Header["kid"] = jwk.KeyID()
|
||||||
|
userJwt, err := userJwtToken.SignedString(signingKey)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+userJwt)
|
||||||
|
ctx, err := ts.API.parseJWTClaims(userJwt, req)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// check if token is stored in context
|
||||||
|
token := getToken(ctx)
|
||||||
|
require.Equal(ts.T(), userJwt, token.Raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() {
|
||||||
|
u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
s, err := models.NewSession(u.ID, nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(s))
|
||||||
|
|
||||||
|
require.NoError(ts.T(), ts.API.db.Load(s))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
Desc string
|
||||||
|
UserJwtClaims *AccessTokenClaims
|
||||||
|
ExpectedError error
|
||||||
|
ExpectedUser *models.User
|
||||||
|
ExpectedSession *models.Session
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Desc: "Missing Subject Claim",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: "",
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
},
|
||||||
|
ExpectedError: forbiddenError(ErrorCodeBadJWT, "invalid claim: missing sub claim"),
|
||||||
|
ExpectedUser: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Valid Subject Claim",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: u.ID.String(),
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
ExpectedUser: u,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Invalid Subject Claim",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: "invalid-subject-claim",
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
},
|
||||||
|
ExpectedError: badRequestError(ErrorCodeBadJWT, "invalid claim: sub claim must be a UUID"),
|
||||||
|
ExpectedUser: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Empty Session ID Claim",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: u.ID.String(),
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
SessionId: "",
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
ExpectedUser: u,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Invalid Session ID Claim",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: u.ID.String(),
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
SessionId: uuid.Nil.String(),
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
ExpectedUser: u,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Valid Session ID Claim",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: u.ID.String(),
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
SessionId: s.ID.String(),
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
ExpectedUser: u,
|
||||||
|
ExpectedSession: s,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Session ID doesn't exist",
|
||||||
|
UserJwtClaims: &AccessTokenClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: u.ID.String(),
|
||||||
|
},
|
||||||
|
Role: "authenticated",
|
||||||
|
SessionId: "73bf9ee0-9e8c-453b-b484-09cb93e2f341",
|
||||||
|
},
|
||||||
|
ExpectedError: forbiddenError(ErrorCodeSessionNotFound, "Session from session_id claim in JWT does not exist").WithInternalError(models.SessionNotFoundError{}).WithInternalMessage("session id (73bf9ee0-9e8c-453b-b484-09cb93e2f341) doesn't exist"),
|
||||||
|
ExpectedUser: u,
|
||||||
|
ExpectedSession: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.Desc, func() {
|
||||||
|
userJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, c.UserJwtClaims).SignedString([]byte(ts.Config.JWT.Secret))
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+userJwt)
|
||||||
|
|
||||||
|
ctx, err := ts.API.parseJWTClaims(userJwt, req)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
ctx, err = ts.API.maybeLoadUserOrSession(ctx)
|
||||||
|
if c.ExpectedError != nil {
|
||||||
|
require.Equal(ts.T(), c.ExpectedError.Error(), err.Error())
|
||||||
|
} else {
|
||||||
|
require.Equal(ts.T(), c.ExpectedError, err)
|
||||||
|
}
|
||||||
|
require.Equal(ts.T(), c.ExpectedUser, getUser(ctx))
|
||||||
|
require.Equal(ts.T(), c.ExpectedSession, getSession(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
func (c contextKey) String() string {
|
||||||
|
return "gotrue api context key " + string(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenKey = contextKey("jwt")
|
||||||
|
inviteTokenKey = contextKey("invite_token")
|
||||||
|
signatureKey = contextKey("signature")
|
||||||
|
externalProviderTypeKey = contextKey("external_provider_type")
|
||||||
|
userKey = contextKey("user")
|
||||||
|
targetUserKey = contextKey("target_user")
|
||||||
|
factorKey = contextKey("factor")
|
||||||
|
sessionKey = contextKey("session")
|
||||||
|
externalReferrerKey = contextKey("external_referrer")
|
||||||
|
functionHooksKey = contextKey("function_hooks")
|
||||||
|
adminUserKey = contextKey("admin_user")
|
||||||
|
oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token
|
||||||
|
oauthVerifierKey = contextKey("oauth_verifier")
|
||||||
|
ssoProviderKey = contextKey("sso_provider")
|
||||||
|
externalHostKey = contextKey("external_host")
|
||||||
|
flowStateKey = contextKey("flow_state_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
// withToken adds the JWT token to the context.
|
||||||
|
func withToken(ctx context.Context, token *jwt.Token) context.Context {
|
||||||
|
return context.WithValue(ctx, tokenKey, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToken reads the JWT token from the context.
|
||||||
|
func getToken(ctx context.Context) *jwt.Token {
|
||||||
|
obj := ctx.Value(tokenKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.(*jwt.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getClaims(ctx context.Context) *AccessTokenClaims {
|
||||||
|
token := getToken(ctx)
|
||||||
|
if token == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return token.Claims.(*AccessTokenClaims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withUser adds the user to the context.
|
||||||
|
func withUser(ctx context.Context, u *models.User) context.Context {
|
||||||
|
return context.WithValue(ctx, userKey, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTargetUser adds the target user for linking to the context.
|
||||||
|
func withTargetUser(ctx context.Context, u *models.User) context.Context {
|
||||||
|
return context.WithValue(ctx, targetUserKey, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// with Factor adds the factor id to the context.
|
||||||
|
func withFactor(ctx context.Context, f *models.Factor) context.Context {
|
||||||
|
return context.WithValue(ctx, factorKey, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUser reads the user from the context.
|
||||||
|
func getUser(ctx context.Context) *models.User {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj := ctx.Value(userKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*models.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTargetUser reads the user from the context.
|
||||||
|
func getTargetUser(ctx context.Context) *models.User {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj := ctx.Value(targetUserKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*models.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFactor reads the factor id from the context
|
||||||
|
func getFactor(ctx context.Context) *models.Factor {
|
||||||
|
obj := ctx.Value(factorKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*models.Factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSession adds the session to the context.
|
||||||
|
func withSession(ctx context.Context, s *models.Session) context.Context {
|
||||||
|
return context.WithValue(ctx, sessionKey, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSession reads the session from the context.
|
||||||
|
func getSession(ctx context.Context) *models.Session {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj := ctx.Value(sessionKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*models.Session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSignature adds the provided request ID to the context.
|
||||||
|
func withSignature(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, signatureKey, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withInviteToken(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, inviteTokenKey, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withFlowStateID(ctx context.Context, FlowStateID string) context.Context {
|
||||||
|
return context.WithValue(ctx, flowStateKey, FlowStateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFlowStateID(ctx context.Context) string {
|
||||||
|
obj := ctx.Value(flowStateKey)
|
||||||
|
if obj == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return obj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInviteToken(ctx context.Context) string {
|
||||||
|
obj := ctx.Value(inviteTokenKey)
|
||||||
|
if obj == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withExternalProviderType adds the provided request ID to the context.
|
||||||
|
func withExternalProviderType(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, externalProviderTypeKey, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getExternalProviderType reads the request ID from the context.
|
||||||
|
func getExternalProviderType(ctx context.Context) string {
|
||||||
|
obj := ctx.Value(externalProviderTypeKey)
|
||||||
|
if obj == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withExternalReferrer(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, externalReferrerKey, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getExternalReferrer(ctx context.Context) string {
|
||||||
|
obj := ctx.Value(externalReferrerKey)
|
||||||
|
if obj == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAdminUser adds the admin user to the context.
|
||||||
|
func withAdminUser(ctx context.Context, u *models.User) context.Context {
|
||||||
|
return context.WithValue(ctx, adminUserKey, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAdminUser reads the admin user from the context.
|
||||||
|
func getAdminUser(ctx context.Context) *models.User {
|
||||||
|
obj := ctx.Value(adminUserKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*models.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withRequestToken adds the request token to the context
|
||||||
|
func withRequestToken(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, oauthTokenKey, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRequestToken(ctx context.Context) string {
|
||||||
|
obj := ctx.Value(oauthTokenKey)
|
||||||
|
if obj == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return obj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withOAuthVerifier(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, oauthVerifierKey, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOAuthVerifier(ctx context.Context) string {
|
||||||
|
obj := ctx.Value(oauthVerifierKey)
|
||||||
|
if obj == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return obj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSSOProvider(ctx context.Context, provider *models.SSOProvider) context.Context {
|
||||||
|
return context.WithValue(ctx, ssoProviderKey, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSSOProvider(ctx context.Context) *models.SSOProvider {
|
||||||
|
obj := ctx.Value(ssoProviderKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*models.SSOProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withExternalHost(ctx context.Context, u *url.URL) context.Context {
|
||||||
|
return context.WithValue(ctx, externalHostKey, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getExternalHost(ctx context.Context) *url.URL {
|
||||||
|
obj := ctx.Value(externalHostKey)
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obj.(*url.URL)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
type ErrorCode = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ErrorCodeUnknown should not be used directly, it only indicates a failure in the error handling system in such a way that an error code was not assigned properly.
|
||||||
|
ErrorCodeUnknown ErrorCode = "unknown"
|
||||||
|
|
||||||
|
// ErrorCodeUnexpectedFailure signals an unexpected failure such as a 500 Internal Server Error.
|
||||||
|
ErrorCodeUnexpectedFailure ErrorCode = "unexpected_failure"
|
||||||
|
|
||||||
|
ErrorCodeValidationFailed ErrorCode = "validation_failed"
|
||||||
|
ErrorCodeBadJSON ErrorCode = "bad_json"
|
||||||
|
ErrorCodeEmailExists ErrorCode = "email_exists"
|
||||||
|
ErrorCodePhoneExists ErrorCode = "phone_exists"
|
||||||
|
ErrorCodeBadJWT ErrorCode = "bad_jwt"
|
||||||
|
ErrorCodeNotAdmin ErrorCode = "not_admin"
|
||||||
|
ErrorCodeNoAuthorization ErrorCode = "no_authorization"
|
||||||
|
ErrorCodeUserNotFound ErrorCode = "user_not_found"
|
||||||
|
ErrorCodeSessionNotFound ErrorCode = "session_not_found"
|
||||||
|
ErrorCodeSessionExpired ErrorCode = "session_expired"
|
||||||
|
ErrorCodeRefreshTokenNotFound ErrorCode = "refresh_token_not_found"
|
||||||
|
ErrorCodeRefreshTokenAlreadyUsed ErrorCode = "refresh_token_already_used"
|
||||||
|
ErrorCodeFlowStateNotFound ErrorCode = "flow_state_not_found"
|
||||||
|
ErrorCodeFlowStateExpired ErrorCode = "flow_state_expired"
|
||||||
|
ErrorCodeSignupDisabled ErrorCode = "signup_disabled"
|
||||||
|
ErrorCodeUserBanned ErrorCode = "user_banned"
|
||||||
|
ErrorCodeProviderEmailNeedsVerification ErrorCode = "provider_email_needs_verification"
|
||||||
|
ErrorCodeInviteNotFound ErrorCode = "invite_not_found"
|
||||||
|
ErrorCodeBadOAuthState ErrorCode = "bad_oauth_state"
|
||||||
|
ErrorCodeBadOAuthCallback ErrorCode = "bad_oauth_callback"
|
||||||
|
ErrorCodeOAuthProviderNotSupported ErrorCode = "oauth_provider_not_supported"
|
||||||
|
ErrorCodeUnexpectedAudience ErrorCode = "unexpected_audience"
|
||||||
|
ErrorCodeSingleIdentityNotDeletable ErrorCode = "single_identity_not_deletable"
|
||||||
|
ErrorCodeEmailConflictIdentityNotDeletable ErrorCode = "email_conflict_identity_not_deletable"
|
||||||
|
ErrorCodeIdentityAlreadyExists ErrorCode = "identity_already_exists"
|
||||||
|
ErrorCodeEmailProviderDisabled ErrorCode = "email_provider_disabled"
|
||||||
|
ErrorCodePhoneProviderDisabled ErrorCode = "phone_provider_disabled"
|
||||||
|
ErrorCodeTooManyEnrolledMFAFactors ErrorCode = "too_many_enrolled_mfa_factors"
|
||||||
|
ErrorCodeMFAFactorNameConflict ErrorCode = "mfa_factor_name_conflict"
|
||||||
|
ErrorCodeMFAFactorNotFound ErrorCode = "mfa_factor_not_found"
|
||||||
|
ErrorCodeMFAIPAddressMismatch ErrorCode = "mfa_ip_address_mismatch"
|
||||||
|
ErrorCodeMFAChallengeExpired ErrorCode = "mfa_challenge_expired"
|
||||||
|
ErrorCodeMFAVerificationFailed ErrorCode = "mfa_verification_failed"
|
||||||
|
ErrorCodeMFAVerificationRejected ErrorCode = "mfa_verification_rejected"
|
||||||
|
ErrorCodeInsufficientAAL ErrorCode = "insufficient_aal"
|
||||||
|
ErrorCodeCaptchaFailed ErrorCode = "captcha_failed"
|
||||||
|
ErrorCodeSAMLProviderDisabled ErrorCode = "saml_provider_disabled"
|
||||||
|
ErrorCodeManualLinkingDisabled ErrorCode = "manual_linking_disabled"
|
||||||
|
ErrorCodeSMSSendFailed ErrorCode = "sms_send_failed"
|
||||||
|
ErrorCodeEmailNotConfirmed ErrorCode = "email_not_confirmed"
|
||||||
|
ErrorCodePhoneNotConfirmed ErrorCode = "phone_not_confirmed"
|
||||||
|
ErrorCodeSAMLRelayStateNotFound ErrorCode = "saml_relay_state_not_found"
|
||||||
|
ErrorCodeSAMLRelayStateExpired ErrorCode = "saml_relay_state_expired"
|
||||||
|
ErrorCodeSAMLIdPNotFound ErrorCode = "saml_idp_not_found"
|
||||||
|
ErrorCodeSAMLAssertionNoUserID ErrorCode = "saml_assertion_no_user_id"
|
||||||
|
ErrorCodeSAMLAssertionNoEmail ErrorCode = "saml_assertion_no_email"
|
||||||
|
ErrorCodeUserAlreadyExists ErrorCode = "user_already_exists"
|
||||||
|
ErrorCodeSSOProviderNotFound ErrorCode = "sso_provider_not_found"
|
||||||
|
ErrorCodeSAMLMetadataFetchFailed ErrorCode = "saml_metadata_fetch_failed"
|
||||||
|
ErrorCodeSAMLIdPAlreadyExists ErrorCode = "saml_idp_already_exists"
|
||||||
|
ErrorCodeSSODomainAlreadyExists ErrorCode = "sso_domain_already_exists"
|
||||||
|
ErrorCodeSAMLEntityIDMismatch ErrorCode = "saml_entity_id_mismatch"
|
||||||
|
ErrorCodeConflict ErrorCode = "conflict"
|
||||||
|
ErrorCodeProviderDisabled ErrorCode = "provider_disabled"
|
||||||
|
ErrorCodeUserSSOManaged ErrorCode = "user_sso_managed"
|
||||||
|
ErrorCodeReauthenticationNeeded ErrorCode = "reauthentication_needed"
|
||||||
|
ErrorCodeSamePassword ErrorCode = "same_password"
|
||||||
|
ErrorCodeReauthenticationNotValid ErrorCode = "reauthentication_not_valid"
|
||||||
|
ErrorCodeOTPExpired ErrorCode = "otp_expired"
|
||||||
|
ErrorCodeOTPDisabled ErrorCode = "otp_disabled"
|
||||||
|
ErrorCodeIdentityNotFound ErrorCode = "identity_not_found"
|
||||||
|
ErrorCodeWeakPassword ErrorCode = "weak_password"
|
||||||
|
ErrorCodeOverRequestRateLimit ErrorCode = "over_request_rate_limit"
|
||||||
|
ErrorCodeOverEmailSendRateLimit ErrorCode = "over_email_send_rate_limit"
|
||||||
|
ErrorCodeOverSMSSendRateLimit ErrorCode = "over_sms_send_rate_limit"
|
||||||
|
ErrorCodeBadCodeVerifier ErrorCode = "bad_code_verifier"
|
||||||
|
ErrorCodeAnonymousProviderDisabled ErrorCode = "anonymous_provider_disabled"
|
||||||
|
ErrorCodeHookTimeout ErrorCode = "hook_timeout"
|
||||||
|
ErrorCodeHookTimeoutAfterRetry ErrorCode = "hook_timeout_after_retry"
|
||||||
|
ErrorCodeHookPayloadOverSizeLimit ErrorCode = "hook_payload_over_size_limit"
|
||||||
|
ErrorCodeHookPayloadInvalidContentType ErrorCode = "hook_payload_invalid_content_type"
|
||||||
|
ErrorCodeRequestTimeout ErrorCode = "request_timeout"
|
||||||
|
ErrorCodeMFAPhoneEnrollDisabled ErrorCode = "mfa_phone_enroll_not_enabled"
|
||||||
|
ErrorCodeMFAPhoneVerifyDisabled ErrorCode = "mfa_phone_verify_not_enabled"
|
||||||
|
ErrorCodeMFATOTPEnrollDisabled ErrorCode = "mfa_totp_enroll_not_enabled"
|
||||||
|
ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled"
|
||||||
|
ErrorCodeMFAWebAuthnEnrollDisabled ErrorCode = "mfa_webauthn_enroll_not_enabled"
|
||||||
|
ErrorCodeMFAWebAuthnVerifyDisabled ErrorCode = "mfa_webauthn_verify_not_enabled"
|
||||||
|
ErrorCodeMFAVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists"
|
||||||
|
//#nosec G101 -- Not a secret value.
|
||||||
|
ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials"
|
||||||
|
ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized"
|
||||||
|
ErrorCodeEmailAddressInvalid ErrorCode = "email_address_invalid"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common error messages during signup flow
|
||||||
|
var (
|
||||||
|
DuplicateEmailMsg = "A user with this email address has already been registered"
|
||||||
|
DuplicatePhoneMsg = "A user with this phone number has already been registered"
|
||||||
|
UserExistsError error = errors.New("user already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
const InvalidChannelError = "Invalid channel, supported values are 'sms' or 'whatsapp'. 'whatsapp' is only supported if Twilio or Twilio Verify is used as the provider."
|
||||||
|
|
||||||
|
var oauthErrorMap = map[int]string{
|
||||||
|
http.StatusBadRequest: "invalid_request",
|
||||||
|
http.StatusUnauthorized: "unauthorized_client",
|
||||||
|
http.StatusForbidden: "access_denied",
|
||||||
|
http.StatusInternalServerError: "server_error",
|
||||||
|
http.StatusServiceUnavailable: "temporarily_unavailable",
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthError is the JSON handler for OAuth2 error responses
|
||||||
|
type OAuthError struct {
|
||||||
|
Err string `json:"error"`
|
||||||
|
Description string `json:"error_description,omitempty"`
|
||||||
|
InternalError error `json:"-"`
|
||||||
|
InternalMessage string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OAuthError) Error() string {
|
||||||
|
if e.InternalMessage != "" {
|
||||||
|
return e.InternalMessage
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.Err, e.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithInternalError adds internal error information to the error
|
||||||
|
func (e *OAuthError) WithInternalError(err error) *OAuthError {
|
||||||
|
e.InternalError = err
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithInternalMessage adds internal message information to the error
|
||||||
|
func (e *OAuthError) WithInternalMessage(fmtString string, args ...interface{}) *OAuthError {
|
||||||
|
e.InternalMessage = fmt.Sprintf(fmtString, args...)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cause returns the root cause error
|
||||||
|
func (e *OAuthError) Cause() error {
|
||||||
|
if e.InternalError != nil {
|
||||||
|
return e.InternalError
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthError(err string, description string) *OAuthError {
|
||||||
|
return &OAuthError{Err: err, Description: description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func badRequestError(errorCode ErrorCode, fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusBadRequest, errorCode, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalServerError(fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusInternalServerError, ErrorCodeUnexpectedFailure, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFoundError(errorCode ErrorCode, fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusNotFound, errorCode, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forbiddenError(errorCode ErrorCode, fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusForbidden, errorCode, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unprocessableEntityError(errorCode ErrorCode, fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusUnprocessableEntity, errorCode, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tooManyRequestsError(errorCode ErrorCode, fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusTooManyRequests, errorCode, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func conflictError(fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return httpError(http.StatusConflict, ErrorCodeConflict, fmtString, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPError is an error with a message and an HTTP status code.
|
||||||
|
type HTTPError struct {
|
||||||
|
HTTPStatus int `json:"code"` // do not rename the JSON tags!
|
||||||
|
ErrorCode string `json:"error_code,omitempty"` // do not rename the JSON tags!
|
||||||
|
Message string `json:"msg"` // do not rename the JSON tags!
|
||||||
|
InternalError error `json:"-"`
|
||||||
|
InternalMessage string `json:"-"`
|
||||||
|
ErrorID string `json:"error_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HTTPError) Error() string {
|
||||||
|
if e.InternalMessage != "" {
|
||||||
|
return e.InternalMessage
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d: %s", e.HTTPStatus, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HTTPError) Is(target error) bool {
|
||||||
|
return e.Error() == target.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cause returns the root cause error
|
||||||
|
func (e *HTTPError) Cause() error {
|
||||||
|
if e.InternalError != nil {
|
||||||
|
return e.InternalError
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithInternalError adds internal error information to the error
|
||||||
|
func (e *HTTPError) WithInternalError(err error) *HTTPError {
|
||||||
|
e.InternalError = err
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithInternalMessage adds internal message information to the error
|
||||||
|
func (e *HTTPError) WithInternalMessage(fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
e.InternalMessage = fmt.Sprintf(fmtString, args...)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpError(httpStatus int, errorCode ErrorCode, fmtString string, args ...interface{}) *HTTPError {
|
||||||
|
return &HTTPError{
|
||||||
|
HTTPStatus: httpStatus,
|
||||||
|
ErrorCode: errorCode,
|
||||||
|
Message: fmt.Sprintf(fmtString, args...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recoverer is a middleware that recovers from panics, logs the panic (and a
|
||||||
|
// backtrace), and returns a HTTP 500 (Internal Server Error) status if
|
||||||
|
// possible. Recoverer prints a request ID if one is provided.
|
||||||
|
func recoverer(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rvr := recover(); rvr != nil {
|
||||||
|
logEntry := observability.GetLogEntry(r)
|
||||||
|
if logEntry != nil {
|
||||||
|
logEntry.Panic(rvr, debug.Stack())
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Panic: %+v\n", rvr)
|
||||||
|
debug.PrintStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
se := &HTTPError{
|
||||||
|
HTTPStatus: http.StatusInternalServerError,
|
||||||
|
Message: http.StatusText(http.StatusInternalServerError),
|
||||||
|
}
|
||||||
|
HandleResponseError(se, w, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCause is an error interface that contains the method Cause() for returning root cause errors
|
||||||
|
type ErrorCause interface {
|
||||||
|
Cause() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPErrorResponse20240101 struct {
|
||||||
|
Code ErrorCode `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := observability.GetLogEntry(r).Entry
|
||||||
|
errorID := utilities.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
apiVersion, averr := DetermineClosestAPIVersion(r.Header.Get(APIVersionHeaderName))
|
||||||
|
if averr != nil {
|
||||||
|
log.WithError(averr).Warn("Invalid version passed to " + APIVersionHeaderName + " header, defaulting to initial version")
|
||||||
|
} else if apiVersion != APIVersionInitial {
|
||||||
|
// Echo back the determined API version from the request
|
||||||
|
w.Header().Set(APIVersionHeaderName, FormatAPIVersion(apiVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e := err.(type) {
|
||||||
|
case *WeakPasswordError:
|
||||||
|
if apiVersion.Compare(APIVersion20240101) >= 0 {
|
||||||
|
var output struct {
|
||||||
|
HTTPErrorResponse20240101
|
||||||
|
Payload struct {
|
||||||
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
|
} `json:"weak_password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
output.Code = ErrorCodeWeakPassword
|
||||||
|
output.Message = e.Message
|
||||||
|
output.Payload.Reasons = e.Reasons
|
||||||
|
|
||||||
|
if jsonErr := sendJSON(w, http.StatusUnprocessableEntity, output); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
var output struct {
|
||||||
|
HTTPError
|
||||||
|
Payload struct {
|
||||||
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
|
} `json:"weak_password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
output.HTTPStatus = http.StatusUnprocessableEntity
|
||||||
|
output.ErrorCode = ErrorCodeWeakPassword
|
||||||
|
output.Message = e.Message
|
||||||
|
output.Payload.Reasons = e.Reasons
|
||||||
|
|
||||||
|
w.Header().Set("x-sb-error-code", output.ErrorCode)
|
||||||
|
|
||||||
|
if jsonErr := sendJSON(w, output.HTTPStatus, output); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case *HTTPError:
|
||||||
|
switch {
|
||||||
|
case e.HTTPStatus >= http.StatusInternalServerError:
|
||||||
|
e.ErrorID = errorID
|
||||||
|
// this will get us the stack trace too
|
||||||
|
log.WithError(e.Cause()).Error(e.Error())
|
||||||
|
case e.HTTPStatus == http.StatusTooManyRequests:
|
||||||
|
log.WithError(e.Cause()).Warn(e.Error())
|
||||||
|
default:
|
||||||
|
log.WithError(e.Cause()).Info(e.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.ErrorCode != "" {
|
||||||
|
w.Header().Set("x-sb-error-code", e.ErrorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiVersion.Compare(APIVersion20240101) >= 0 {
|
||||||
|
resp := HTTPErrorResponse20240101{
|
||||||
|
Code: e.ErrorCode,
|
||||||
|
Message: e.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Code == "" {
|
||||||
|
if e.HTTPStatus == http.StatusInternalServerError {
|
||||||
|
resp.Code = ErrorCodeUnexpectedFailure
|
||||||
|
} else {
|
||||||
|
resp.Code = ErrorCodeUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonErr := sendJSON(w, e.HTTPStatus, resp); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if e.ErrorCode == "" {
|
||||||
|
if e.HTTPStatus == http.StatusInternalServerError {
|
||||||
|
e.ErrorCode = ErrorCodeUnexpectedFailure
|
||||||
|
} else {
|
||||||
|
e.ErrorCode = ErrorCodeUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide better error messages for certain user-triggered Postgres errors.
|
||||||
|
if pgErr := utilities.NewPostgresError(e.InternalError); pgErr != nil {
|
||||||
|
if jsonErr := sendJSON(w, pgErr.HttpStatusCode, pgErr); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonErr := sendJSON(w, e.HTTPStatus, e); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case *OAuthError:
|
||||||
|
log.WithError(e.Cause()).Info(e.Error())
|
||||||
|
if jsonErr := sendJSON(w, http.StatusBadRequest, e); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
|
||||||
|
case ErrorCause:
|
||||||
|
HandleResponseError(e.Cause(), w, r)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.WithError(e).Errorf("Unhandled server error: %s", e.Error())
|
||||||
|
|
||||||
|
if apiVersion.Compare(APIVersion20240101) >= 0 {
|
||||||
|
resp := HTTPErrorResponse20240101{
|
||||||
|
Code: ErrorCodeUnexpectedFailure,
|
||||||
|
Message: "Unexpected failure, please check server logs for more information",
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonErr := sendJSON(w, http.StatusInternalServerError, resp); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
httpError := HTTPError{
|
||||||
|
HTTPStatus: http.StatusInternalServerError,
|
||||||
|
ErrorCode: ErrorCodeUnexpectedFailure,
|
||||||
|
Message: "Unexpected failure, please check server logs for more information",
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonErr := sendJSON(w, http.StatusInternalServerError, httpError); jsonErr != nil && jsonErr != context.DeadlineExceeded {
|
||||||
|
log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFrequencyLimitErrorMessage(timeStamp *time.Time, maxFrequency time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
left := timeStamp.Add(maxFrequency).Sub(now) / time.Second
|
||||||
|
return fmt.Sprintf("For security purposes, you can only request this after %d seconds.", left)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleResponseErrorWithHTTPError(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
HTTPError *HTTPError
|
||||||
|
APIVersion string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
HTTPError: badRequestError(ErrorCodeBadJSON, "Unable to parse JSON"),
|
||||||
|
APIVersion: "",
|
||||||
|
ExpectedBody: "{\"code\":400,\"error_code\":\"" + ErrorCodeBadJSON + "\",\"msg\":\"Unable to parse JSON\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HTTPError: badRequestError(ErrorCodeBadJSON, "Unable to parse JSON"),
|
||||||
|
APIVersion: "2023-12-31",
|
||||||
|
ExpectedBody: "{\"code\":400,\"error_code\":\"" + ErrorCodeBadJSON + "\",\"msg\":\"Unable to parse JSON\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HTTPError: badRequestError(ErrorCodeBadJSON, "Unable to parse JSON"),
|
||||||
|
APIVersion: "2024-01-01",
|
||||||
|
ExpectedBody: "{\"code\":\"" + ErrorCodeBadJSON + "\",\"message\":\"Unable to parse JSON\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HTTPError: &HTTPError{
|
||||||
|
HTTPStatus: http.StatusBadRequest,
|
||||||
|
Message: "Uncoded failure",
|
||||||
|
},
|
||||||
|
APIVersion: "2024-01-01",
|
||||||
|
ExpectedBody: "{\"code\":\"" + ErrorCodeUnknown + "\",\"message\":\"Uncoded failure\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HTTPError: &HTTPError{
|
||||||
|
HTTPStatus: http.StatusInternalServerError,
|
||||||
|
Message: "Unexpected failure",
|
||||||
|
},
|
||||||
|
APIVersion: "2024-01-01",
|
||||||
|
ExpectedBody: "{\"code\":\"" + ErrorCodeUnexpectedFailure + "\",\"message\":\"Unexpected failure\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, example := range examples {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "http://example.com", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if example.APIVersion != "" {
|
||||||
|
req.Header.Set(APIVersionHeaderName, example.APIVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleResponseError(example.HTTPError, rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, example.HTTPError.HTTPStatus, rec.Code)
|
||||||
|
require.Equal(t, example.ExpectedBody, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverer(t *testing.T) {
|
||||||
|
var logBuffer bytes.Buffer
|
||||||
|
config, err := conf.LoadGlobal(apiTestConfig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, observability.ConfigureLogging(&config.Logging))
|
||||||
|
|
||||||
|
// logrus should write to the buffer so we can check if the logs are output correctly
|
||||||
|
logrus.SetOutput(&logBuffer)
|
||||||
|
panicHandler := recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic("test panic")
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "http://example.com", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
panicHandler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
var data HTTPError
|
||||||
|
|
||||||
|
// panic should return an internal server error
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.Equal(t, ErrorCodeUnexpectedFailure, data.ErrorCode)
|
||||||
|
require.Equal(t, http.StatusInternalServerError, data.HTTPStatus)
|
||||||
|
require.Equal(t, "Internal Server Error", data.Message)
|
||||||
|
|
||||||
|
// panic should log the error message internally
|
||||||
|
var logs map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(&logBuffer).Decode(&logs))
|
||||||
|
require.Equal(t, "request panicked", logs["msg"])
|
||||||
|
require.Equal(t, "test panic", logs["panic"])
|
||||||
|
require.NotEmpty(t, logs["stack"])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,684 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExternalProviderClaims are the JWT claims sent as the state in the external oauth provider signup flow
|
||||||
|
type ExternalProviderClaims struct {
|
||||||
|
AuthMicroserviceClaims
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
InviteToken string `json:"invite_token,omitempty"`
|
||||||
|
Referrer string `json:"referrer,omitempty"`
|
||||||
|
FlowStateID string `json:"flow_state_id"`
|
||||||
|
LinkingTargetID string `json:"linking_target_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalProviderRedirect redirects the request to the oauth provider
|
||||||
|
func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
rurl, err := a.GetExternalProviderRedirectURL(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, rurl, http.StatusFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExternalProviderRedirectURL returns the URL to start the oauth flow with the corresponding oauth provider
|
||||||
|
func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Request, linkingTargetUser *models.User) (string, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
config := a.config
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
providerType := query.Get("provider")
|
||||||
|
scopes := query.Get("scopes")
|
||||||
|
codeChallenge := query.Get("code_challenge")
|
||||||
|
codeChallengeMethod := query.Get("code_challenge_method")
|
||||||
|
|
||||||
|
p, err := a.Provider(ctx, providerType, scopes)
|
||||||
|
if err != nil {
|
||||||
|
return "", badRequestError(ErrorCodeValidationFailed, "Unsupported provider: %+v", err).WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteToken := query.Get("invite_token")
|
||||||
|
if inviteToken != "" {
|
||||||
|
_, userErr := models.FindUserByConfirmationToken(db, inviteToken)
|
||||||
|
if userErr != nil {
|
||||||
|
if models.IsNotFoundError(userErr) {
|
||||||
|
return "", notFoundError(ErrorCodeUserNotFound, "User identified by token not found")
|
||||||
|
}
|
||||||
|
return "", internalServerError("Database error finding user").WithInternalError(userErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL := utilities.GetReferrer(r, config)
|
||||||
|
log := observability.GetLogEntry(r).Entry
|
||||||
|
log.WithField("provider", providerType).Info("Redirecting to external provider")
|
||||||
|
if err := validatePKCEParams(codeChallengeMethod, codeChallenge); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
flowType := getFlowFromChallenge(codeChallenge)
|
||||||
|
|
||||||
|
flowStateID := ""
|
||||||
|
if isPKCEFlow(flowType) {
|
||||||
|
flowState, err := generateFlowState(a.db, providerType, models.OAuth, codeChallengeMethod, codeChallenge, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
flowStateID = flowState.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{
|
||||||
|
AuthMicroserviceClaims: AuthMicroserviceClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
||||||
|
},
|
||||||
|
SiteURL: config.SiteURL,
|
||||||
|
InstanceID: uuid.Nil.String(),
|
||||||
|
},
|
||||||
|
Provider: providerType,
|
||||||
|
InviteToken: inviteToken,
|
||||||
|
Referrer: redirectURL,
|
||||||
|
FlowStateID: flowStateID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if linkingTargetUser != nil {
|
||||||
|
// this means that the user is performing manual linking
|
||||||
|
claims.LinkingTargetID = linkingTargetUser.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString, err := signJwt(&config.JWT, claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", internalServerError("Error creating state").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authUrlParams := make([]oauth2.AuthCodeOption, 0)
|
||||||
|
query.Del("scopes")
|
||||||
|
query.Del("provider")
|
||||||
|
query.Del("code_challenge")
|
||||||
|
query.Del("code_challenge_method")
|
||||||
|
for key := range query {
|
||||||
|
if key == "workos_provider" {
|
||||||
|
// See https://workos.com/docs/reference/sso/authorize/get
|
||||||
|
authUrlParams = append(authUrlParams, oauth2.SetAuthURLParam("provider", query.Get(key)))
|
||||||
|
} else {
|
||||||
|
authUrlParams = append(authUrlParams, oauth2.SetAuthURLParam(key, query.Get(key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := p.AuthCodeURL(tokenString, authUrlParams...)
|
||||||
|
|
||||||
|
return authURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalProviderCallback handles the callback endpoint in the external oauth provider flow
|
||||||
|
func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
rurl := a.getExternalRedirectURL(r)
|
||||||
|
u, err := url.Parse(rurl)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
redirectErrors(a.internalExternalProviderCallback, w, r, u)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleOAuthCallback(r *http.Request) (*OAuthProviderData, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
providerType := getExternalProviderType(ctx)
|
||||||
|
|
||||||
|
var oAuthResponseData *OAuthProviderData
|
||||||
|
var err error
|
||||||
|
switch providerType {
|
||||||
|
case "twitter":
|
||||||
|
// future OAuth1.0 providers will use this method
|
||||||
|
oAuthResponseData, err = a.oAuth1Callback(ctx, providerType)
|
||||||
|
default:
|
||||||
|
oAuthResponseData, err = a.oAuthCallback(ctx, r, providerType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return oAuthResponseData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
|
||||||
|
var grantParams models.GrantParams
|
||||||
|
grantParams.FillGrantParams(r)
|
||||||
|
|
||||||
|
providerType := getExternalProviderType(ctx)
|
||||||
|
data, err := a.handleOAuthCallback(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := data.userData
|
||||||
|
if len(userData.Emails) <= 0 {
|
||||||
|
return internalServerError("Error getting user email from external provider")
|
||||||
|
}
|
||||||
|
userData.Metadata.EmailVerified = false
|
||||||
|
for _, email := range userData.Emails {
|
||||||
|
if email.Primary {
|
||||||
|
userData.Metadata.Email = email.Email
|
||||||
|
userData.Metadata.EmailVerified = email.Verified
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
userData.Metadata.Email = email.Email
|
||||||
|
userData.Metadata.EmailVerified = email.Verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
providerAccessToken := data.token
|
||||||
|
providerRefreshToken := data.refreshToken
|
||||||
|
|
||||||
|
var flowState *models.FlowState
|
||||||
|
// if there's a non-empty FlowStateID we perform PKCE Flow
|
||||||
|
if flowStateID := getFlowStateID(ctx); flowStateID != "" {
|
||||||
|
flowState, err = models.FindFlowStateByID(a.db, flowStateID)
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return unprocessableEntityError(ErrorCodeFlowStateNotFound, "Flow state not found").WithInternalError(err)
|
||||||
|
} else if err != nil {
|
||||||
|
return internalServerError("Failed to find flow state").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *models.User
|
||||||
|
var token *AccessTokenResponse
|
||||||
|
err = db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
var terr error
|
||||||
|
if targetUser := getTargetUser(ctx); targetUser != nil {
|
||||||
|
if user, terr = a.linkIdentityToUser(r, ctx, tx, userData, providerType); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
} else if inviteToken := getInviteToken(ctx); inviteToken != "" {
|
||||||
|
if user, terr = a.processInvite(r, tx, userData, inviteToken, providerType); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flowState != nil {
|
||||||
|
// This means that the callback is using PKCE
|
||||||
|
flowState.ProviderAccessToken = providerAccessToken
|
||||||
|
flowState.ProviderRefreshToken = providerRefreshToken
|
||||||
|
flowState.UserID = &(user.ID)
|
||||||
|
issueTime := time.Now()
|
||||||
|
flowState.AuthCodeIssuedAt = &issueTime
|
||||||
|
|
||||||
|
terr = tx.Update(flowState)
|
||||||
|
} else {
|
||||||
|
token, terr = a.issueRefreshToken(r, tx, user, models.OAuth, grantParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr != nil {
|
||||||
|
return oauthError("server_error", terr.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rurl := a.getExternalRedirectURL(r)
|
||||||
|
if flowState != nil {
|
||||||
|
// This means that the callback is using PKCE
|
||||||
|
// Set the flowState.AuthCode to the query param here
|
||||||
|
rurl, err = a.prepPKCERedirectURL(rurl, flowState.AuthCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if token != nil {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("provider_token", providerAccessToken)
|
||||||
|
// Because not all providers give out a refresh token
|
||||||
|
// See corresponding OAuth2 spec: <https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1>
|
||||||
|
if providerRefreshToken != "" {
|
||||||
|
q.Set("provider_refresh_token", providerRefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
rurl = token.AsRedirectURL(rurl, q)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, rurl, http.StatusFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.Request, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
aud := a.requestAud(ctx, r)
|
||||||
|
config := a.config
|
||||||
|
|
||||||
|
var user *models.User
|
||||||
|
var identity *models.Identity
|
||||||
|
var identityData map[string]interface{}
|
||||||
|
if userData.Metadata != nil {
|
||||||
|
identityData = structs.Map(userData.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, terr := models.DetermineAccountLinking(tx, config, userData.Emails, aud, providerType, userData.Metadata.Subject)
|
||||||
|
if terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
switch decision.Decision {
|
||||||
|
case models.LinkAccount:
|
||||||
|
user = decision.User
|
||||||
|
|
||||||
|
if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr = user.UpdateUserMetaData(tx, identityData); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
case models.CreateAccount:
|
||||||
|
if config.DisableSignup {
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeSignupDisabled, "Signups not allowed for this instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &SignupParams{
|
||||||
|
Provider: providerType,
|
||||||
|
Email: decision.CandidateEmail.Email,
|
||||||
|
Aud: aud,
|
||||||
|
Data: identityData,
|
||||||
|
}
|
||||||
|
|
||||||
|
isSSOUser := false
|
||||||
|
if strings.HasPrefix(decision.LinkingDomain, "sso:") {
|
||||||
|
isSSOUser = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// because params above sets no password, this method is not
|
||||||
|
// computationally hard so it can be used within a database
|
||||||
|
// transaction
|
||||||
|
user, terr = params.ToUserModel(isSSOUser)
|
||||||
|
if terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if user, terr = a.signupNewUser(tx, user); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
user.Identities = append(user.Identities, *identity)
|
||||||
|
case models.AccountExists:
|
||||||
|
user = decision.User
|
||||||
|
identity = decision.Identities[0]
|
||||||
|
|
||||||
|
identity.IdentityData = identityData
|
||||||
|
if terr = tx.UpdateOnly(identity, "identity_data", "last_sign_in_at"); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
if terr = user.UpdateUserMetaData(tx, identityData); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
case models.MultipleAccounts:
|
||||||
|
return nil, internalServerError("Multiple accounts with the same email address in the same linking domain detected: %v", decision.LinkingDomain)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, internalServerError("Unknown automatic linking decision: %v", decision.Decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsBanned() {
|
||||||
|
return nil, forbiddenError(ErrorCodeUserBanned, "User is banned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsConfirmed() {
|
||||||
|
// The user may have other unconfirmed email + password
|
||||||
|
// combination, phone or oauth identities. These identities
|
||||||
|
// need to be removed when a new oauth identity is being added
|
||||||
|
// to prevent pre-account takeover attacks from happening.
|
||||||
|
if terr = user.RemoveUnconfirmedIdentities(tx, identity); terr != nil {
|
||||||
|
return nil, internalServerError("Error updating user").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
if decision.CandidateEmail.Verified || config.Mailer.Autoconfirm {
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{
|
||||||
|
"provider": providerType,
|
||||||
|
}); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
// fall through to auto-confirm and issue token
|
||||||
|
if terr = user.Confirm(tx); terr != nil {
|
||||||
|
return nil, internalServerError("Error updating user").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emailConfirmationSent := false
|
||||||
|
if decision.CandidateEmail.Email != "" {
|
||||||
|
if terr = a.sendConfirmation(r, tx, user, models.ImplicitFlow); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
emailConfirmationSent = true
|
||||||
|
}
|
||||||
|
if !config.Mailer.AllowUnverifiedEmailSignIns {
|
||||||
|
if emailConfirmationSent {
|
||||||
|
return nil, storage.NewCommitWithError(unprocessableEntityError(ErrorCodeProviderEmailNeedsVerification, fmt.Sprintf("Unverified email with %v. A confirmation email has been sent to your %v email", providerType, providerType)))
|
||||||
|
}
|
||||||
|
return nil, storage.NewCommitWithError(unprocessableEntityError(ErrorCodeProviderEmailNeedsVerification, fmt.Sprintf("Unverified email with %v. Verify the email with %v in order to sign in", providerType, providerType)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{
|
||||||
|
"provider": providerType,
|
||||||
|
}); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) processInvite(r *http.Request, tx *storage.Connection, userData *provider.UserProvidedData, inviteToken, providerType string) (*models.User, error) {
|
||||||
|
user, err := models.FindUserByConfirmationToken(tx, inviteToken)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return nil, notFoundError(ErrorCodeInviteNotFound, "Invite not found")
|
||||||
|
}
|
||||||
|
return nil, internalServerError("Database error finding user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailData *provider.Email
|
||||||
|
var emails []string
|
||||||
|
for i, e := range userData.Emails {
|
||||||
|
emails = append(emails, e.Email)
|
||||||
|
if user.GetEmail() == e.Email {
|
||||||
|
emailData = &userData.Emails[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if emailData == nil {
|
||||||
|
return nil, badRequestError(ErrorCodeValidationFailed, "Invited email does not match emails from external provider").WithInternalMessage("invited=%s external=%s", user.Email, strings.Join(emails, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
var identityData map[string]interface{}
|
||||||
|
if userData.Metadata != nil {
|
||||||
|
identityData = structs.Map(userData.Metadata)
|
||||||
|
}
|
||||||
|
identity, err := a.createNewIdentity(tx, user, providerType, identityData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := user.UpdateAppMetaData(tx, map[string]interface{}{
|
||||||
|
"provider": providerType,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := user.UpdateAppMetaDataProviders(tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := user.UpdateUserMetaData(tx, identityData); err != nil {
|
||||||
|
return nil, internalServerError("Database error updating user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.NewAuditLogEntry(r, tx, user, models.InviteAcceptedAction, "", map[string]interface{}{
|
||||||
|
"provider": providerType,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// an account with a previously unconfirmed email + password
|
||||||
|
// combination or phone may exist. so now that there is an
|
||||||
|
// OAuth identity bound to this user, and since they have not
|
||||||
|
// confirmed their email or phone, they are unaware that a
|
||||||
|
// potentially malicious door exists into their account; thus
|
||||||
|
// the password and phone needs to be removed.
|
||||||
|
if err := user.RemoveUnconfirmedIdentities(tx, identity); err != nil {
|
||||||
|
return nil, internalServerError("Error updating user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm because they were able to respond to invite email
|
||||||
|
if err := user.Confirm(tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) loadExternalState(ctx context.Context, r *http.Request) (context.Context, error) {
|
||||||
|
var state string
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
state = r.FormValue("state")
|
||||||
|
default:
|
||||||
|
state = r.URL.Query().Get("state")
|
||||||
|
}
|
||||||
|
if state == "" {
|
||||||
|
return ctx, badRequestError(ErrorCodeBadOAuthCallback, "OAuth state parameter missing")
|
||||||
|
}
|
||||||
|
config := a.config
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods(config.JWT.ValidMethods))
|
||||||
|
_, err := p.ParseWithClaims(state, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if kid, ok := token.Header["kid"]; ok {
|
||||||
|
if kidStr, ok := kid.(string); ok {
|
||||||
|
return conf.FindPublicKeyByKid(kidStr, &config.JWT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alg, ok := token.Header["alg"]; ok {
|
||||||
|
if alg == jwt.SigningMethodHS256.Name {
|
||||||
|
// preserve backward compatibility for cases where the kid is not set
|
||||||
|
return []byte(config.JWT.Secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("missing kid")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ctx, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state").WithInternalError(err)
|
||||||
|
}
|
||||||
|
if claims.Provider == "" {
|
||||||
|
return ctx, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state (missing provider)")
|
||||||
|
}
|
||||||
|
if claims.InviteToken != "" {
|
||||||
|
ctx = withInviteToken(ctx, claims.InviteToken)
|
||||||
|
}
|
||||||
|
if claims.Referrer != "" {
|
||||||
|
ctx = withExternalReferrer(ctx, claims.Referrer)
|
||||||
|
}
|
||||||
|
if claims.FlowStateID != "" {
|
||||||
|
ctx = withFlowStateID(ctx, claims.FlowStateID)
|
||||||
|
}
|
||||||
|
if claims.LinkingTargetID != "" {
|
||||||
|
linkingTargetUserID, err := uuid.FromString(claims.LinkingTargetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, badRequestError(ErrorCodeBadOAuthState, "OAuth callback with invalid state (linking_target_id must be UUID)")
|
||||||
|
}
|
||||||
|
u, err := models.FindUserByID(a.db, linkingTargetUserID)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsNotFoundError(err) {
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeUserNotFound, "Linking target user not found")
|
||||||
|
}
|
||||||
|
return nil, internalServerError("Database error loading user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
ctx = withTargetUser(ctx, u)
|
||||||
|
}
|
||||||
|
ctx = withExternalProviderType(ctx, claims.Provider)
|
||||||
|
return withSignature(ctx, state), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider returns a Provider interface for the given name.
|
||||||
|
func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, error) {
|
||||||
|
config := a.config
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "apple":
|
||||||
|
return provider.NewAppleProvider(ctx, config.External.Apple)
|
||||||
|
case "azure":
|
||||||
|
return provider.NewAzureProvider(config.External.Azure, scopes)
|
||||||
|
case "bitbucket":
|
||||||
|
return provider.NewBitbucketProvider(config.External.Bitbucket)
|
||||||
|
case "discord":
|
||||||
|
return provider.NewDiscordProvider(config.External.Discord, scopes)
|
||||||
|
case "facebook":
|
||||||
|
return provider.NewFacebookProvider(config.External.Facebook, scopes)
|
||||||
|
case "figma":
|
||||||
|
return provider.NewFigmaProvider(config.External.Figma, scopes)
|
||||||
|
case "fly":
|
||||||
|
return provider.NewFlyProvider(config.External.Fly, scopes)
|
||||||
|
case "github":
|
||||||
|
return provider.NewGithubProvider(config.External.Github, scopes)
|
||||||
|
case "gitlab":
|
||||||
|
return provider.NewGitlabProvider(config.External.Gitlab, scopes)
|
||||||
|
case "google":
|
||||||
|
return provider.NewGoogleProvider(ctx, config.External.Google, scopes)
|
||||||
|
case "kakao":
|
||||||
|
return provider.NewKakaoProvider(config.External.Kakao, scopes)
|
||||||
|
case "keycloak":
|
||||||
|
return provider.NewKeycloakProvider(config.External.Keycloak, scopes)
|
||||||
|
case "linkedin":
|
||||||
|
return provider.NewLinkedinProvider(config.External.Linkedin, scopes)
|
||||||
|
case "linkedin_oidc":
|
||||||
|
return provider.NewLinkedinOIDCProvider(config.External.LinkedinOIDC, scopes)
|
||||||
|
case "notion":
|
||||||
|
return provider.NewNotionProvider(config.External.Notion)
|
||||||
|
case "spotify":
|
||||||
|
return provider.NewSpotifyProvider(config.External.Spotify, scopes)
|
||||||
|
case "slack":
|
||||||
|
return provider.NewSlackProvider(config.External.Slack, scopes)
|
||||||
|
case "slack_oidc":
|
||||||
|
return provider.NewSlackOIDCProvider(config.External.SlackOIDC, scopes)
|
||||||
|
case "twitch":
|
||||||
|
return provider.NewTwitchProvider(config.External.Twitch, scopes)
|
||||||
|
case "twitter":
|
||||||
|
return provider.NewTwitterProvider(config.External.Twitter, scopes)
|
||||||
|
case "vercel_marketplace":
|
||||||
|
return provider.NewVercelMarketplaceProvider(config.External.VercelMarketplace, scopes)
|
||||||
|
case "workos":
|
||||||
|
return provider.NewWorkOSProvider(config.External.WorkOS)
|
||||||
|
case "zoom":
|
||||||
|
return provider.NewZoomProvider(config.External.Zoom)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Provider %s could not be found", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request, u *url.URL) {
|
||||||
|
ctx := r.Context()
|
||||||
|
log := observability.GetLogEntry(r).Entry
|
||||||
|
errorID := utilities.GetRequestID(ctx)
|
||||||
|
err := handler(w, r)
|
||||||
|
if err != nil {
|
||||||
|
q := getErrorQueryString(err, errorID, log, u.Query())
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// TODO: deprecate returning error details in the query fragment
|
||||||
|
hq := url.Values{}
|
||||||
|
if q.Get("error") != "" {
|
||||||
|
hq.Set("error", q.Get("error"))
|
||||||
|
}
|
||||||
|
if q.Get("error_description") != "" {
|
||||||
|
hq.Set("error_description", q.Get("error_description"))
|
||||||
|
}
|
||||||
|
if q.Get("error_code") != "" {
|
||||||
|
hq.Set("error_code", q.Get("error_code"))
|
||||||
|
}
|
||||||
|
u.Fragment = hq.Encode()
|
||||||
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getErrorQueryString(err error, errorID string, log logrus.FieldLogger, q url.Values) *url.Values {
|
||||||
|
switch e := err.(type) {
|
||||||
|
case *HTTPError:
|
||||||
|
if e.ErrorCode == ErrorCodeSignupDisabled {
|
||||||
|
q.Set("error", "access_denied")
|
||||||
|
} else if e.ErrorCode == ErrorCodeUserBanned {
|
||||||
|
q.Set("error", "access_denied")
|
||||||
|
} else if e.ErrorCode == ErrorCodeProviderEmailNeedsVerification {
|
||||||
|
q.Set("error", "access_denied")
|
||||||
|
} else if str, ok := oauthErrorMap[e.HTTPStatus]; ok {
|
||||||
|
q.Set("error", str)
|
||||||
|
} else {
|
||||||
|
q.Set("error", "server_error")
|
||||||
|
}
|
||||||
|
if e.HTTPStatus >= http.StatusInternalServerError {
|
||||||
|
e.ErrorID = errorID
|
||||||
|
// this will get us the stack trace too
|
||||||
|
log.WithError(e.Cause()).Error(e.Error())
|
||||||
|
} else {
|
||||||
|
log.WithError(e.Cause()).Info(e.Error())
|
||||||
|
}
|
||||||
|
q.Set("error_description", e.Message)
|
||||||
|
q.Set("error_code", e.ErrorCode)
|
||||||
|
case *OAuthError:
|
||||||
|
q.Set("error", e.Err)
|
||||||
|
q.Set("error_description", e.Description)
|
||||||
|
log.WithError(e.Cause()).Info(e.Error())
|
||||||
|
case ErrorCause:
|
||||||
|
return getErrorQueryString(e.Cause(), errorID, log, q)
|
||||||
|
default:
|
||||||
|
error_type, error_description := "server_error", err.Error()
|
||||||
|
|
||||||
|
// Provide better error messages for certain user-triggered Postgres errors.
|
||||||
|
if pgErr := utilities.NewPostgresError(e); pgErr != nil {
|
||||||
|
error_description = pgErr.Message
|
||||||
|
if oauthErrorType, ok := oauthErrorMap[pgErr.HttpStatusCode]; ok {
|
||||||
|
error_type = oauthErrorType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q.Set("error", error_type)
|
||||||
|
q.Set("error_description", error_description)
|
||||||
|
}
|
||||||
|
return &q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) getExternalRedirectURL(r *http.Request) string {
|
||||||
|
ctx := r.Context()
|
||||||
|
config := a.config
|
||||||
|
if config.External.RedirectURL != "" {
|
||||||
|
return config.External.RedirectURL
|
||||||
|
}
|
||||||
|
if er := getExternalReferrer(ctx); er != "" {
|
||||||
|
return er
|
||||||
|
}
|
||||||
|
return config.SiteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) createNewIdentity(tx *storage.Connection, user *models.User, providerType string, identityData map[string]interface{}) (*models.Identity, error) {
|
||||||
|
identity, err := models.NewIdentity(user, providerType, identityData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := tx.Create(identity); terr != nil {
|
||||||
|
return nil, internalServerError("Error creating identity").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalApple() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=apple", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Apple.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Apple.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("email name", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("apple", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
azureUser string = `{"name":"Azure Test","email":"azure@example.com","sub":"azuretestid"}`
|
||||||
|
azureUserNoEmail string = `{"name":"Azure Test","sub":"azuretestid"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func idTokenPrivateKey() *rsa.PrivateKey {
|
||||||
|
// #nosec
|
||||||
|
der, err := base64.StdEncoding.DecodeString("MIIEpAIBAAKCAQEAvklrFDsVgbhs3DOQICMqm4xdFoi/MHj/T6XH8S7wXWd0roqdWVarwCLV4y3DILkLre4PzNK+hEY5NAnoAKrsCMyyCb4Wdl8HCdJk4ojDqAig+DJw67imqZoxJMFJyIhfMJhwVK1V8GRUPATn855rygLo7wThahMJeEHNiJr3TtV6Rf35KSs7DuyoWIUSjISYabQozKqIvpdUpTpSqjlOQvjdAxggRyycBZSgLzjWhsA8metnAMO48bX4bgiHLR6Kzu/dfPyEVPfgeYpA2ebIY6GzIUxVS0yX8+ExA6jeLCkuepjLHuz5XCJtd6zzGDXr1eX7nA6ZIeUNdFbWRDnPawIDAQABAoIBABH4Qvl1HvHSJc2hvPGcAJER71SKc2uzcYDnCfu30BEyDO3Sv0tJiQyq/YHnt26mqviw66MPH9jD/PDyIou1mHa4RfPvlJV3IeYGjWprOfbrYbAuq0VHec24dv2el0YtwreHHcyRVfVOtDm6yODTzCAWqEKyNktbIuDNbgiBgetayaJecDRoFMF9TOCeMCL92iZytzAr7fi+JWtLkRS/GZRIBjbr8LJ/ueYoCRmIx3MIw0WdPp7v2ZfeRTxP7LxJZ+MAsrq2pstmZYP7K0305e0bCJX1HexfXLs2Ul7u8zaxrXL8zw4/9+/GMsAeU3ffCVnGz/RKL5+T6iuz2RotjFECgYEA+Xk7DGwRXfDg9xba1GVFGeiC4nybqZw/RfZKcz/RRJWSHRJV/ps1avtbca3B19rjI6rewZMO1NWNv/tI2BdXP8vAKUnI9OHJZ+J/eZzmqDE6qu0v0ddRFUDzCMWE0j8BjrUdy44n4NQgopcv14u0iyr9tuhGO6YXn2SuuvEkZokCgYEAw0PNnT55kpkEhXSp7An2hdBJEub9ST7hS6Kcd8let62/qUZ/t5jWigSkWC1A2bMtH55+LgudIFjiehwVzRs7jym2j4jkKZGonyAX1l9IWgXwKl7Pn49lEQH5Yk6MhnXdyLGoFTzXiUyk/fKvgXX7jow1bD3j6sAc8P495I7TyVMCgYAHg6VJrH+har37805IE3zPWPeIRuSRaUlmnBKGAigVfsPV6FV6w8YKIOQSOn+aNtecnWr0Pa+2rXAFllYNXDaej06Mb9KDvcFJRcM9MIKqEkGIIHjOQ0QH9drcKsbjZk5vs/jfxrpgxULuYstoHKclgff+aGSlK02O2YOB0f2csQKBgQCEC/MdNiWCpKXxFg7fB3HF1i/Eb56zjKlQu7uyKeQ6tG3bLEisQNg8Z5034Apt7gRC0KyluMbeHB2z1BBOLu9dBill8X3SOqVcTpiwKKlF76QVEx622YLQOJSMDXBscYK0+KchDY74U3N0JEzZcI7YPCrYcxYRJy+rLVNvn8LK7wKBgQDE8THsZ589e10F0zDBvPK56o8PJnPeH71sgdM2Co4oLzBJ6g0rpJOKfcc03fLHsoJVOAya9WZeIy6K8+WVdcPTadR07S4p8/tcK1eguu5qlmCUOzswrTKAaJoIHO7cddQp3nySIqgYtkGdHKuvlQDMQkEKJS0meOm+vdeAG2rkaA==")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := x509.ParsePKCS1PrivateKey(der)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey.E = 65537
|
||||||
|
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAzureOverrideVerifiers() {
|
||||||
|
provider.OverrideVerifiers["https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/oauth2/v2.0/authorize"] = func(ctx context.Context, config *oidc.Config) *oidc.IDTokenVerifier {
|
||||||
|
pk := idTokenPrivateKey()
|
||||||
|
|
||||||
|
return oidc.NewVerifier(
|
||||||
|
provider.IssuerAzureMicrosoft,
|
||||||
|
&oidc.StaticKeySet{
|
||||||
|
PublicKeys: []crypto.PublicKey{
|
||||||
|
&pk.PublicKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintIDToken(user string) string {
|
||||||
|
var idToken struct {
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
IssuedAt int `json:"iat"`
|
||||||
|
ExpiresAt int `json:"exp"`
|
||||||
|
Audience string `json:"aud"`
|
||||||
|
|
||||||
|
Sub string `json:"sub,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
XmsEdov any `json:"xms_edov,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(user), &idToken); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
idToken.Issuer = provider.IssuerAzureMicrosoft
|
||||||
|
idToken.IssuedAt = int(now.Unix())
|
||||||
|
idToken.ExpiresAt = int(now.Unix() + 60*60)
|
||||||
|
idToken.Audience = "testclientid"
|
||||||
|
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"RS256"}`))
|
||||||
|
|
||||||
|
data, err := json.Marshal(idToken)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString(data)
|
||||||
|
sum := sha256.Sum256([]byte(header + "." + payload))
|
||||||
|
|
||||||
|
pk := idTokenPrivateKey()
|
||||||
|
sig, err := rsa.SignPKCS1v15(nil, pk, crypto.SHA256, sum[:])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalAzure() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=azure", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Azure.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Azure.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("openid", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("azure", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AzureTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth2/v2.0/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Azure.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"access_token":"azure_token","expires_in":100000,"id_token":%q}`, mintIDToken(user))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown azure oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Azure.URL = server.URL
|
||||||
|
ts.Config.External.Azure.ApiURL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalAzure_AuthorizationCode() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "azure", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, -1, "azure@example.com", "Azure Test", "azuretestid", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupErrorWhenNoUser() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "azure", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "azure@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupErrorWhenNoEmail() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "azure", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "azure@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("azuretestid", "azure@example.com", "Azure Test", "", "")
|
||||||
|
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "azure", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, -1, "azure@example.com", "Azure Test", "azuretestid", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalAzureSuccessWhenMatchingToken() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
// name should be populated from Azure API
|
||||||
|
ts.createUser("azuretestid", "azure@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "azure", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, -1, "azure@example.com", "Azure Test", "azuretestid", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalAzureErrorWhenNoMatchingToken() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
azureUser := `{"name":"Azure Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "azure", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalAzureErrorWhenWrongToken() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
ts.createUser("azuretestid", "azure@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
azureUser := `{"name":"Azure Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "azure", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalAzureErrorWhenEmailDoesntMatch() {
|
||||||
|
setupAzureOverrideVerifiers()
|
||||||
|
|
||||||
|
ts.createUser("azuretestid", "azure@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount := 0
|
||||||
|
code := "authcode"
|
||||||
|
azureUser := `{"name":"Azure Test", "email":"other@example.com", "avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
server := AzureTestSignupSetup(ts, &tokenCount, code, azureUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "azure", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bitbucketUser string = `{"uuid":"bitbucketTestId","display_name":"Bitbucket Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalBitbucket() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=bitbucket", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Bitbucket.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Bitbucket.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("account email", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("bitbucket", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BitbucketTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string, emails string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/site/oauth2/access_token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Bitbucket.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"bitbucket_token","expires_in":100000}`)
|
||||||
|
case "/2.0/user":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
case "/2.0/user/emails":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, emails)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown bitbucket oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Bitbucket.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalBitbucket_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `{"values":[{"email":"bitbucket@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "bitbucket@example.com", "Bitbucket Test", "bitbucketTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalBitbucketDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
bitbucketUser := `{"display_name":"Bitbucket Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
emails := `{"values":[{"email":"bitbucket@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "bitbucket@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalBitbucketDisableSignupErrorWhenNoEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `{"values":[{}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "bitbucket@example.com")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalBitbucketDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("bitbucketTestId", "bitbucket@example.com", "Bitbucket Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `{"values":[{"email":"bitbucket@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "bitbucket@example.com", "Bitbucket Test", "bitbucketTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalBitbucketDisableSignupSuccessWithSecondaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("bitbucketTestId", "secondary@example.com", "Bitbucket Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `{"values":[{"email":"primary@example.com","is_primary":true,"is_confirmed":true},{"email":"secondary@example.com","is_primary":false,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "secondary@example.com", "Bitbucket Test", "bitbucketTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalBitbucketSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Bitbucket API
|
||||||
|
ts.createUser("bitbucketTestId", "bitbucket@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `{"values":[{"email":"bitbucket@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "bitbucket@example.com", "Bitbucket Test", "bitbucketTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalBitbucketErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
bitbucketUser := `{"display_name":"Bitbucket Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
emails := `{"values":[{"email":"bitbucket@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "bitbucket", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalBitbucketErrorWhenWrongToken() {
|
||||||
|
ts.createUser("bitbucketTestId", "bitbucket@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
bitbucketUser := `{"display_name":"Bitbucket Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
emails := `{"values":[{"email":"bitbucket@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "bitbucket", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalBitbucketErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("bitbucketTestId", "bitbucket@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `{"values":[{"email":"other@example.com","is_primary":true,"is_confirmed":true}]}`
|
||||||
|
server := BitbucketTestSignupSetup(ts, &tokenCount, &userCount, code, bitbucketUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "bitbucket", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
discordUser string = `{"id":"discordTestId","avatar":"abc","email":"discord@example.com","username":"Discord Test","verified":true,"discriminator":"0001"}}`
|
||||||
|
discordUserWrongEmail string = `{"id":"discordTestId","avatar":"abc","email":"other@example.com","username":"Discord Test","verified":true}}`
|
||||||
|
discordUserNoEmail string = `{"id":"discordTestId","avatar":"abc","username":"Discord Test","verified":true}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalDiscord() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=discord", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Discord.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Discord.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("email identify", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("discord", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscordTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/oauth2/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Discord.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"discord_token","expires_in":100000}`)
|
||||||
|
case "/api/users/@me":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown discord oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Discord.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalDiscord_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "discord", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "discord@example.com", "Discord Test", "discordTestId", "https://cdn.discordapp.com/avatars/discordTestId/abc.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalDiscordDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "discord", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "discord@example.com")
|
||||||
|
}
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalDiscordDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "discord", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "discord@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalDiscordDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("discordTestId", "discord@example.com", "Discord Test", "https://cdn.discordapp.com/avatars/discordTestId/abc.png", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "discord", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "discord@example.com", "Discord Test", "discordTestId", "https://cdn.discordapp.com/avatars/discordTestId/abc.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalDiscordSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Discord API
|
||||||
|
ts.createUser("discordTestId", "discord@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "discord", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "discord@example.com", "Discord Test", "discordTestId", "https://cdn.discordapp.com/avatars/discordTestId/abc.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalDiscordErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "discord", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalDiscordErrorWhenWrongToken() {
|
||||||
|
ts.createUser("discordTestId", "discord@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "discord", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalDiscordErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("discordTestId", "discord@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := DiscordTestSignupSetup(ts, &tokenCount, &userCount, code, discordUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "discord", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
facebookUser string = `{"id":"facebookTestId","name":"Facebook Test","first_name":"Facebook","last_name":"Test","email":"facebook@example.com","picture":{"data":{"url":"http://example.com/avatar"}}}}`
|
||||||
|
facebookUserWrongEmail string = `{"id":"facebookTestId","name":"Facebook Test","first_name":"Facebook","last_name":"Test","email":"other@example.com","picture":{"data":{"url":"http://example.com/avatar"}}}}`
|
||||||
|
facebookUserNoEmail string = `{"id":"facebookTestId","name":"Facebook Test","first_name":"Facebook","last_name":"Test","picture":{"data":{"url":"http://example.com/avatar"}}}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFacebook() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=facebook", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Facebook.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Facebook.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("email", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("facebook", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FacebookTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/access_token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Facebook.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"facebook_token","expires_in":100000}`)
|
||||||
|
case "/me":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown facebook oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Facebook.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFacebook_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "facebook", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "facebook@example.com", "Facebook Test", "facebookTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFacebookDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "facebook", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "facebook@example.com")
|
||||||
|
}
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFacebookDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "facebook", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "facebook@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFacebookDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("facebookTestId", "facebook@example.com", "Facebook Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "facebook", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "facebook@example.com", "Facebook Test", "facebookTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFacebookSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Facebook API
|
||||||
|
ts.createUser("facebookTestId", "facebook@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "facebook", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "facebook@example.com", "Facebook Test", "facebookTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFacebookErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "facebook", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFacebookErrorWhenWrongToken() {
|
||||||
|
ts.createUser("facebookTestId", "facebook@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "facebook", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFacebookErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("facebookTestId", "facebook@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := FacebookTestSignupSetup(ts, &tokenCount, &userCount, code, facebookUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "facebook", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigma() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=figma", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Figma.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Figma.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("files:read", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("figma", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FigmaTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, email string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/oauth/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Figma.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"figma_token","expires_in":100000,"refresh_token":"figma_token"}`)
|
||||||
|
case "/v1/me":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"id":"figma-test-id","email":"%s","handle":"Figma Test","img_url":"http://example.com/avatar"}`, email)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown figma oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Figma.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigma_AuthorizationCode() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "figma@example.com", "Figma Test", "figma-test-id", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigma_PKCE() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
|
||||||
|
// for the plain challenge method, the code verifier == code challenge
|
||||||
|
// code challenge has to be between 43 - 128 chars for the plain challenge method
|
||||||
|
codeVerifier := "testtesttesttesttesttesttesttesttesttesttesttesttesttest"
|
||||||
|
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
codeChallengeMethod string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "SHA256",
|
||||||
|
codeChallengeMethod: "s256",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
var codeChallenge string
|
||||||
|
if c.codeChallengeMethod == "s256" {
|
||||||
|
hashedCodeVerifier := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
codeChallenge = base64.RawURLEncoding.EncodeToString(hashedCodeVerifier[:])
|
||||||
|
} else {
|
||||||
|
codeChallenge = codeVerifier
|
||||||
|
}
|
||||||
|
// Check for valid auth code returned
|
||||||
|
u := performPKCEAuthorization(ts, "figma", code, codeChallenge, c.codeChallengeMethod)
|
||||||
|
m, err := url.ParseQuery(u.RawQuery)
|
||||||
|
authCode := m["code"][0]
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), authCode)
|
||||||
|
|
||||||
|
// Check for valid provider access token, mock does not return refresh token
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "figma@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), user)
|
||||||
|
flowState, err := models.FindFlowStateByAuthCode(ts.API.db, authCode)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Equal(ts.T(), "figma_token", flowState.ProviderAccessToken)
|
||||||
|
|
||||||
|
// Exchange Auth Code for token
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"code_verifier": codeVerifier,
|
||||||
|
"auth_code": authCode,
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=pkce", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Validate that access token and provider tokens are present
|
||||||
|
data := AccessTokenResponse{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.NotEmpty(ts.T(), data.Token)
|
||||||
|
require.NotEmpty(ts.T(), data.RefreshToken)
|
||||||
|
require.NotEmpty(ts.T(), data.ProviderAccessToken)
|
||||||
|
require.Equal(ts.T(), data.User.ID, user.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigmaDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "figma@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigmaDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := ""
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "figma@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigmaDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("figma-test-id", "figma@example.com", "Figma Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "figma@example.com", "Figma Test", "figma-test-id", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFigmaSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Figma API
|
||||||
|
ts.createUser("figma-test-id", "figma@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "figma@example.com", "Figma Test", "figma-test-id", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFigmaErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "figma", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFigmaErrorWhenWrongToken() {
|
||||||
|
ts.createUser("figma-test-id", "figma@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "figma", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFigmaErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("figma-test-id", "figma@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "other@example.com"
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFigmaErrorWhenUserBanned() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "figma@example.com"
|
||||||
|
|
||||||
|
server := FigmaTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "figma", code, "")
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "figma@example.com", "Figma Test", "figma-test-id", "http://example.com/avatar")
|
||||||
|
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "figma@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
t := time.Now().Add(24 * time.Hour)
|
||||||
|
user.BannedUntil = &t
|
||||||
|
require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
|
||||||
|
|
||||||
|
u = performAuthorization(ts, "figma", code, "")
|
||||||
|
assertAuthorizationFailure(ts, u, "User is banned", "access_denied", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFly() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=fly", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Fly.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Fly.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("read", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("fly", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FlyTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, email string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Fly.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"fly_token","expires_in":100000,"refresh_token":"fly_refresh_token"}`)
|
||||||
|
case "/oauth/token/info":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"resource_owner_id":"test_resource_owner_id","scope":["read"],"expires_in":1111,"application":{"uid":"test_app_uid"},"created_at":1696003692,"user_id":"test_user_id","user_name":"test_user","email":"%s","organizations":[{"id":"test_org_id","role":"test"}]}`, email)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown fly oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Fly.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFly_AuthorizationCode() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "fly@example.com", "test_user", "test_user_id", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFly_PKCE() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
|
||||||
|
// for the plain challenge method, the code verifier == code challenge
|
||||||
|
// code challenge has to be between 43 - 128 chars for the plain challenge method
|
||||||
|
codeVerifier := "testtesttesttesttesttesttesttesttesttesttesttesttesttest"
|
||||||
|
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
codeChallengeMethod string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "SHA256",
|
||||||
|
codeChallengeMethod: "s256",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
var codeChallenge string
|
||||||
|
if c.codeChallengeMethod == "s256" {
|
||||||
|
hashedCodeVerifier := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
codeChallenge = base64.RawURLEncoding.EncodeToString(hashedCodeVerifier[:])
|
||||||
|
} else {
|
||||||
|
codeChallenge = codeVerifier
|
||||||
|
}
|
||||||
|
// Check for valid auth code returned
|
||||||
|
u := performPKCEAuthorization(ts, "fly", code, codeChallenge, c.codeChallengeMethod)
|
||||||
|
m, err := url.ParseQuery(u.RawQuery)
|
||||||
|
authCode := m["code"][0]
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), authCode)
|
||||||
|
|
||||||
|
// Check for valid provider access token, mock does not return refresh token
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "fly@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), user)
|
||||||
|
flowState, err := models.FindFlowStateByAuthCode(ts.API.db, authCode)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Equal(ts.T(), "fly_token", flowState.ProviderAccessToken)
|
||||||
|
|
||||||
|
// Exchange Auth Code for token
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"code_verifier": codeVerifier,
|
||||||
|
"auth_code": authCode,
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=pkce", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Validate that access token and provider tokens are present
|
||||||
|
data := AccessTokenResponse{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.NotEmpty(ts.T(), data.Token)
|
||||||
|
require.NotEmpty(ts.T(), data.RefreshToken)
|
||||||
|
require.NotEmpty(ts.T(), data.ProviderAccessToken)
|
||||||
|
require.Equal(ts.T(), data.User.ID, user.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFlyDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFlyDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := ""
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "fly@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFlyDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("test_user_id", "fly@example.com", "test_user", "", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "fly@example.com", "test_user", "test_user_id", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFlySuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from fly API
|
||||||
|
ts.createUser("test_user_id", "fly@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "fly@example.com", "test_user", "test_user_id", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFlyErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "fly", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFlyErrorWhenWrongToken() {
|
||||||
|
ts.createUser("test_user_id", "fly@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "fly", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalFlyErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("test_user_id", "fly@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "other@example.com"
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalFlyErrorWhenUserBanned() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
email := "fly@example.com"
|
||||||
|
|
||||||
|
server := FlyTestSignupSetup(ts, &tokenCount, &userCount, code, email)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "fly", code, "")
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "fly@example.com", "test_user", "test_user_id", "")
|
||||||
|
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "fly@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
t := time.Now().Add(24 * time.Hour)
|
||||||
|
user.BannedUntil = &t
|
||||||
|
require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
|
||||||
|
|
||||||
|
u = performAuthorization(ts, "fly", code, "")
|
||||||
|
assertAuthorizationFailure(ts, u, "User is banned", "access_denied", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGithub() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=github", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Github.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Github.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("user:email", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("github", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GitHubTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/login/oauth/access_token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Github.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"github_token","expires_in":100000}`)
|
||||||
|
case "/api/v3/user":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"id":123, "name":"GitHub Test","avatar_url":"http://example.com/avatar"}`)
|
||||||
|
case "/api/v3/user/emails":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, emails)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown github oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Github.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHub_AuthorizationCode() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "github@example.com", "GitHub Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHub_PKCE() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
|
||||||
|
// for the plain challenge method, the code verifier == code challenge
|
||||||
|
// code challenge has to be between 43 - 128 chars for the plain challenge method
|
||||||
|
codeVerifier := "testtesttesttesttesttesttesttesttesttesttesttesttesttest"
|
||||||
|
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
codeChallengeMethod string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "SHA256",
|
||||||
|
codeChallengeMethod: "s256",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Plain",
|
||||||
|
codeChallengeMethod: "plain",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
var codeChallenge string
|
||||||
|
if c.codeChallengeMethod == "s256" {
|
||||||
|
hashedCodeVerifier := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
codeChallenge = base64.RawURLEncoding.EncodeToString(hashedCodeVerifier[:])
|
||||||
|
} else {
|
||||||
|
codeChallenge = codeVerifier
|
||||||
|
}
|
||||||
|
// Check for valid auth code returned
|
||||||
|
u := performPKCEAuthorization(ts, "github", code, codeChallenge, c.codeChallengeMethod)
|
||||||
|
m, err := url.ParseQuery(u.RawQuery)
|
||||||
|
authCode := m["code"][0]
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), authCode)
|
||||||
|
|
||||||
|
// Check for valid provider access token, mock does not return refresh token
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "github@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NotEmpty(ts.T(), user)
|
||||||
|
flowState, err := models.FindFlowStateByAuthCode(ts.API.db, authCode)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Equal(ts.T(), "github_token", flowState.ProviderAccessToken)
|
||||||
|
|
||||||
|
// Exchange Auth Code for token
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"code_verifier": codeVerifier,
|
||||||
|
"auth_code": authCode,
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=pkce", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Validate that access token and provider tokens are present
|
||||||
|
data := AccessTokenResponse{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.NotEmpty(ts.T(), data.Token)
|
||||||
|
require.NotEmpty(ts.T(), data.RefreshToken)
|
||||||
|
require.NotEmpty(ts.T(), data.ProviderAccessToken)
|
||||||
|
require.Equal(ts.T(), data.User.ID, user.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHubDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "github@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHubDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "github@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHubDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("123", "github@example.com", "GitHub Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "github@example.com", "GitHub Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHubDisableSignupSuccessWithNonPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("123", "secondary@example.com", "GitHub Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"primary@example.com", "primary": true, "verified": true},{"email":"secondary@example.com", "primary": false, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "secondary@example.com", "GitHub Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitHubSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from GitHub API
|
||||||
|
ts.createUser("123", "github@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "github@example.com", "GitHub Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitHubErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "github", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitHubErrorWhenWrongToken() {
|
||||||
|
ts.createUser("123", "github@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "github", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitHubErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("123", "github@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"other@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHubErrorWhenVerifiedFalse() {
|
||||||
|
ts.Config.Mailer.AllowUnverifiedEmailSignIns = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": false}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Unverified email with github. A confirmation email has been sent to your github email", "access_denied", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitHubErrorWhenUserBanned() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "github", code, "")
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "github@example.com", "GitHub Test", "123", "http://example.com/avatar")
|
||||||
|
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "github@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
t := time.Now().Add(24 * time.Hour)
|
||||||
|
user.BannedUntil = &t
|
||||||
|
require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
|
||||||
|
|
||||||
|
u = performAuthorization(ts, "github", code, "")
|
||||||
|
assertAuthorizationFailure(ts, u, "User is banned", "access_denied", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gitlabUser string = `{"id":123,"email":"gitlab@example.com","name":"GitLab Test","avatar_url":"http://example.com/avatar","confirmed_at":"2012-05-23T09:05:22Z"}`
|
||||||
|
gitlabUserWrongEmail string = `{"id":123,"email":"other@example.com","name":"GitLab Test","avatar_url":"http://example.com/avatar","confirmed_at":"2012-05-23T09:05:22Z"}`
|
||||||
|
gitlabUserNoEmail string = `{"id":123,"name":"Gitlab Test","avatar_url":"http://example.com/avatar"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitlab() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=gitlab", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Gitlab.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Gitlab.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("read_user", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("gitlab", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GitlabTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string, emails string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Gitlab.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"gitlab_token","expires_in":100000}`)
|
||||||
|
case "/api/v4/user":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
case "/api/v4/user/emails":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, emails)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown gitlab oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Gitlab.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitlab_AuthorizationCode() {
|
||||||
|
// additional emails from GitLab don't return confirm status
|
||||||
|
ts.Config.Mailer.Autoconfirm = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"id":1,"email":"gitlab@example.com"}]`
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "gitlab@example.com", "GitLab Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitLabDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"id":1,"email":"gitlab@example.com"}]`
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "gitlab@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitLabDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[]`
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUserNoEmail, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "gitlab@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitLabDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("123", "gitlab@example.com", "GitLab Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := "[]"
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "gitlab@example.com", "GitLab Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGitLabDisableSignupSuccessWithSecondaryEmail() {
|
||||||
|
// additional emails from GitLab don't return confirm status
|
||||||
|
ts.Config.Mailer.Autoconfirm = true
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("123", "secondary@example.com", "GitLab Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"id":1,"email":"secondary@example.com"}]`
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "secondary@example.com", "GitLab Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitLabSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from GitLab API
|
||||||
|
ts.createUser("123", "gitlab@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := "[]"
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "gitlab@example.com", "GitLab Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitLabErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := "[]"
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "gitlab", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitLabErrorWhenWrongToken() {
|
||||||
|
ts.createUser("123", "gitlab@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := "[]"
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUser, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "gitlab", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGitLabErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("123", "gitlab@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := "[]"
|
||||||
|
server := GitlabTestSignupSetup(ts, &tokenCount, &userCount, code, gitlabUserWrongEmail, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "gitlab", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
googleUser string = `{"id":"googleTestId","name":"Google Test","picture":"http://example.com/avatar","email":"google@example.com","verified_email":true}}`
|
||||||
|
googleUserWrongEmail string = `{"id":"googleTestId","name":"Google Test","picture":"http://example.com/avatar","email":"other@example.com","verified_email":true}}`
|
||||||
|
googleUserNoEmail string = `{"id":"googleTestId","name":"Google Test","picture":"http://example.com/avatar","verified_email":false}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGoogle() {
|
||||||
|
provider.ResetGoogleProvider()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=google", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Google.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Google.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("email profile", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("google", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GoogleTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
provider.ResetGoogleProvider()
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/.well-known/openid-configuration":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": server.URL,
|
||||||
|
"token_endpoint": server.URL + "/o/oauth2/token",
|
||||||
|
}))
|
||||||
|
case "/o/oauth2/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Google.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"google_token","expires_in":100000}`)
|
||||||
|
case "/userinfo/v2/me":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown google oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
provider.OverrideGoogleProvider(server.URL, server.URL+"/userinfo/v2/me")
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGoogle_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "google", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "google@example.com", "Google Test", "googleTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGoogleDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "google", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "google@example.com")
|
||||||
|
}
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGoogleDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "google", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "google@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalGoogleDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("googleTestId", "google@example.com", "Google Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "google", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "google@example.com", "Google Test", "googleTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGoogleSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Google API
|
||||||
|
ts.createUser("googleTestId", "google@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "google", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "google@example.com", "Google Test", "googleTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGoogleErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "google", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGoogleErrorWhenWrongToken() {
|
||||||
|
ts.createUser("googleTestId", "google@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "google", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalGoogleErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("googleTestId", "google@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := GoogleTestSignupSetup(ts, &tokenCount, &userCount, code, googleUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "google", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakao() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=kakao", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Kakao.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Kakao.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("kakao", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KakaoTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Kakao.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"kakao_token","expires_in":100000}`)
|
||||||
|
case "/v2/user/me":
|
||||||
|
*userCount++
|
||||||
|
var emailList []provider.Email
|
||||||
|
if err := json.Unmarshal([]byte(emails), &emailList); err != nil {
|
||||||
|
ts.Fail("Invalid email json %s", emails)
|
||||||
|
}
|
||||||
|
|
||||||
|
var email *provider.Email
|
||||||
|
|
||||||
|
for i, e := range emailList {
|
||||||
|
if len(e.Email) > 0 {
|
||||||
|
email = &emailList[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
if email != nil {
|
||||||
|
fmt.Fprintf(w, `
|
||||||
|
{
|
||||||
|
"id":123,
|
||||||
|
"kakao_account": {
|
||||||
|
"profile": {
|
||||||
|
"nickname":"Kakao Test",
|
||||||
|
"profile_image_url":"http://example.com/avatar"
|
||||||
|
},
|
||||||
|
"email": "%v",
|
||||||
|
"is_email_valid": %v,
|
||||||
|
"is_email_verified": %v
|
||||||
|
}
|
||||||
|
}`, email.Email, email.Verified, email.Verified)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
{
|
||||||
|
"id":123,
|
||||||
|
"kakao_account": {
|
||||||
|
"profile": {
|
||||||
|
"nickname":"Kakao Test",
|
||||||
|
"profile_image_url":"http://example.com/avatar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown kakao oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
ts.Config.External.Kakao.URL = server.URL
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakao_AuthorizationCode() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
u := performAuthorization(ts, "kakao", code, "")
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "kakao@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "kakao@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("123", "kakao@example.com", "Kakao Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Kakao API
|
||||||
|
ts.createUser("123", "kakao@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "kakao", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenWrongToken() {
|
||||||
|
ts.createUser("123", "kakao@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "kakao", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("123", "kakao@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"other@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenVerifiedFalse() {
|
||||||
|
ts.Config.Mailer.AllowUnverifiedEmailSignIns = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": false}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Unverified email with kakao. A confirmation email has been sent to your kakao email", "access_denied", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenUserBanned() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
|
||||||
|
server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "kakao", code, "")
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
|
||||||
|
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "kakao@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
t := time.Now().Add(24 * time.Hour)
|
||||||
|
user.BannedUntil = &t
|
||||||
|
require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
|
||||||
|
|
||||||
|
u = performAuthorization(ts, "kakao", code, "")
|
||||||
|
assertAuthorizationFailure(ts, u, "User is banned", "access_denied", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keycloakUser string = `{"sub": "keycloaktestid", "name": "Keycloak Test", "email": "keycloak@example.com", "preferred_username": "keycloak", "email_verified": true}`
|
||||||
|
keycloakUserNoEmail string = `{"sub": "keycloaktestid", "name": "Keycloak Test", "preferred_username": "keycloak", "email_verified": false}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKeycloak() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=keycloak", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Keycloak.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Keycloak.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("profile email", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("keycloak", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KeycloakTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/protocol/openid-connect/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Keycloak.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"keycloak_token","expires_in":100000}`)
|
||||||
|
case "/protocol/openid-connect/userinfo":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown keycloak oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Keycloak.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKeycloakWithoutURLSetup() {
|
||||||
|
ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "", "")
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
ts.Config.External.Keycloak.URL = ""
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "keycloak", code)
|
||||||
|
ts.Equal(w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKeycloak_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "", "")
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "keycloak", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "keycloak", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "keycloak@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupErrorWhenNoEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "keycloak", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "keycloak@example.com")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "keycloak", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Keycloak API
|
||||||
|
ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "keycloak", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
keycloakUser := `{"name":"Keycloak Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "keycloak", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenWrongToken() {
|
||||||
|
ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
keycloakUser := `{"name":"Keycloak Test","avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "keycloak", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
keycloakUser := `{"name":"Keycloak Test", "email":"other@example.com", "avatar":{"href":"http://example.com/avatar"}}`
|
||||||
|
server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "keycloak", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
linkedinUser string = `{"id":"linkedinTestId","firstName":{"localized":{"en_US":"Linkedin"},"preferredLocale":{"country":"US","language":"en"}},"lastName":{"localized":{"en_US":"Test"},"preferredLocale":{"country":"US","language":"en"}},"profilePicture":{"displayImage~":{"elements":[{"identifiers":[{"identifier":"http://example.com/avatar"}]}]}}}`
|
||||||
|
linkedinUserNoProfilePic string = `{"id":"linkedinTestId","firstName":{"localized":{"en_US":"Linkedin"},"preferredLocale":{"country":"US","language":"en"}},"lastName":{"localized":{"en_US":"Test"},"preferredLocale":{"country":"US","language":"en"}},"profilePicture":{"displayImage~":{"elements":[]}}}`
|
||||||
|
linkedinEmail string = `{"elements": [{"handle": "","handle~": {"emailAddress": "linkedin@example.com"}}]}`
|
||||||
|
linkedinWrongEmail string = `{"elements": [{"handle": "","handle~": {"emailAddress": "other@example.com"}}]}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalLinkedin() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=linkedin", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Linkedin.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Linkedin.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("r_emailaddress r_liteprofile", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("linkedin", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LinkedinTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string, email string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/v2/accessToken":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Linkedin.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"linkedin_token","expires_in":100000}`)
|
||||||
|
case "/v2/me":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
case "/v2/emailAddress":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, email)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown linkedin oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Linkedin.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalLinkedin_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "linkedin", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalLinkedinDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "linkedin", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "linkedin@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalLinkedinDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("linkedinTestId", "linkedin@example.com", "Linkedin Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "linkedin", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Linkedin API
|
||||||
|
ts.createUser("linkedinTestId", "linkedin@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "linkedin", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "linkedin", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenWrongToken() {
|
||||||
|
ts.createUser("linkedinTestId", "linkedin@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "linkedin", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("linkedinTestId", "linkedin@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUser, linkedinWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "linkedin", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalLinkedin_MissingProfilePic() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUserNoProfilePic, linkedinEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "linkedin", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
notionUser string = `{"bot":{"owner":{"user":{"id":"notionTestId","name":"Notion Test","avatar_url":"http://example.com/avatar","person":{"email":"notion@example.com"},"verified_email":true}}}}`
|
||||||
|
notionUserWrongEmail string = `{"bot":{"owner":{"user":{"id":"notionTestId","name":"Notion Test","avatar_url":"http://example.com/avatar","person":{"email":"other@example.com"},"verified_email":true}}}}`
|
||||||
|
notionUserNoEmail string = `{"bot":{"owner":{"user":{"id":"notionTestId","name":"Notion Test","avatar_url":"http://example.com/avatar","verified_email":true}}}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalNotion() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=notion", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Notion.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Notion.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("notion", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotionTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/oauth/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Notion.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"notion_token","expires_in":100000}`)
|
||||||
|
case "/v1/users/me":
|
||||||
|
*userCount++
|
||||||
|
ts.Contains(r.Header, "Authorization")
|
||||||
|
ts.Contains(r.Header, "Notion-Version")
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown notion oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Notion.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalNotion_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "notion", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "notion@example.com", "Notion Test", "notionTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalNotionDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "notion", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "notion@example.com")
|
||||||
|
}
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalNotionDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "notion", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "notion@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalNotionDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("notionTestId", "notion@example.com", "Notion Test", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "notion", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "notion@example.com", "Notion Test", "notionTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalNotionSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Notion API
|
||||||
|
ts.createUser("notionTestId", "notion@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "notion", code, "invite_token")
|
||||||
|
|
||||||
|
fmt.Printf("%+v\n", u)
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "notion@example.com", "Notion Test", "notionTestId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalNotionErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "notion", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalNotionErrorWhenWrongToken() {
|
||||||
|
ts.createUser("notionTestId", "notion@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "notion", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalNotionErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("notionTestId", "notion@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "notion", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/mrjones/oauth"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthProviderData contains the userData and token returned by the oauth provider
|
||||||
|
type OAuthProviderData struct {
|
||||||
|
userData *provider.UserProvidedData
|
||||||
|
token string
|
||||||
|
refreshToken string
|
||||||
|
code string
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFlowState parses the `state` query parameter as a JWS payload,
|
||||||
|
// extracting the provider requested
|
||||||
|
func (a *API) loadFlowState(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
oauthToken := r.URL.Query().Get("oauth_token")
|
||||||
|
if oauthToken != "" {
|
||||||
|
ctx = withRequestToken(ctx, oauthToken)
|
||||||
|
}
|
||||||
|
oauthVerifier := r.URL.Query().Get("oauth_verifier")
|
||||||
|
if oauthVerifier != "" {
|
||||||
|
ctx = withOAuthVerifier(ctx, oauthVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
ctx, err = a.loadExternalState(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
u, uerr := url.ParseRequestURI(a.config.SiteURL)
|
||||||
|
if uerr != nil {
|
||||||
|
return ctx, internalServerError("site url is improperly formatted").WithInternalError(uerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := getErrorQueryString(err, utilities.GetRequestID(ctx), observability.GetLogEntry(r).Entry, u.Query())
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
return ctx, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType string) (*OAuthProviderData, error) {
|
||||||
|
var rq url.Values
|
||||||
|
if err := r.ParseForm(); r.Method == http.MethodPost && err == nil {
|
||||||
|
rq = r.Form
|
||||||
|
} else {
|
||||||
|
rq = r.URL.Query()
|
||||||
|
}
|
||||||
|
|
||||||
|
extError := rq.Get("error")
|
||||||
|
if extError != "" {
|
||||||
|
return nil, oauthError(extError, rq.Get("error_description"))
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthCode := rq.Get("code")
|
||||||
|
if oauthCode == "" {
|
||||||
|
return nil, badRequestError(ErrorCodeBadOAuthCallback, "OAuth callback with missing authorization code missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
oAuthProvider, err := a.OAuthProvider(ctx, providerType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, badRequestError(ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %+v", err).WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := observability.GetLogEntry(r).Entry
|
||||||
|
log.WithFields(logrus.Fields{
|
||||||
|
"provider": providerType,
|
||||||
|
"code": oauthCode,
|
||||||
|
}).Debug("Exchanging oauth code")
|
||||||
|
|
||||||
|
token, err := oAuthProvider.GetOAuthToken(oauthCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalServerError("Unable to exchange external code: %s", oauthCode).WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userData, err := oAuthProvider.GetUserData(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalServerError("Error getting user profile from external provider").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch externalProvider := oAuthProvider.(type) {
|
||||||
|
case *provider.AppleProvider:
|
||||||
|
// apple only returns user info the first time
|
||||||
|
oauthUser := rq.Get("user")
|
||||||
|
if oauthUser != "" {
|
||||||
|
err := externalProvider.ParseUser(oauthUser, userData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OAuthProviderData{
|
||||||
|
userData: userData,
|
||||||
|
token: token.AccessToken,
|
||||||
|
refreshToken: token.RefreshToken,
|
||||||
|
code: oauthCode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) oAuth1Callback(ctx context.Context, providerType string) (*OAuthProviderData, error) {
|
||||||
|
oAuthProvider, err := a.OAuthProvider(ctx, providerType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, badRequestError(ErrorCodeOAuthProviderNotSupported, "Unsupported provider: %+v", err).WithInternalError(err)
|
||||||
|
}
|
||||||
|
oauthToken := getRequestToken(ctx)
|
||||||
|
oauthVerifier := getOAuthVerifier(ctx)
|
||||||
|
var accessToken *oauth.AccessToken
|
||||||
|
var userData *provider.UserProvidedData
|
||||||
|
if twitterProvider, ok := oAuthProvider.(*provider.TwitterProvider); ok {
|
||||||
|
accessToken, err = twitterProvider.Consumer.AuthorizeToken(&oauth.RequestToken{
|
||||||
|
Token: oauthToken,
|
||||||
|
}, oauthVerifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalServerError("Unable to retrieve access token").WithInternalError(err)
|
||||||
|
}
|
||||||
|
userData, err = twitterProvider.FetchUserData(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalServerError("Error getting user email from external provider").WithInternalError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OAuthProviderData{
|
||||||
|
userData: userData,
|
||||||
|
token: accessToken.Token,
|
||||||
|
refreshToken: "",
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthProvider returns the corresponding oauth provider as an OAuthProvider interface
|
||||||
|
func (a *API) OAuthProvider(ctx context.Context, name string) (provider.OAuthProvider, error) {
|
||||||
|
providerCandidate, err := a.Provider(ctx, name, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p := providerCandidate.(type) {
|
||||||
|
case provider.OAuthProvider:
|
||||||
|
return p, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Provider %v cannot be used for OAuth", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalSlackOIDC() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=slack_oidc", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Slack.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Slack.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("profile email openid", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("slack_oidc", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExternalTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternal(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &ExternalTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) SetupTest() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
ts.Config.Mailer.Autoconfirm = false
|
||||||
|
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) createUser(providerId string, email string, name string, avatar string, confirmationToken string) (*models.User, error) {
|
||||||
|
// Cleanup existing user, if they already exist
|
||||||
|
if u, _ := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud); u != nil {
|
||||||
|
require.NoError(ts.T(), ts.API.db.Destroy(u), "Error deleting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := map[string]interface{}{"provider_id": providerId, "full_name": name}
|
||||||
|
if avatar != "" {
|
||||||
|
userData["avatar_url"] = avatar
|
||||||
|
}
|
||||||
|
u, err := models.NewUser("", email, "test", ts.Config.JWT.Aud, userData)
|
||||||
|
|
||||||
|
if confirmationToken != "" {
|
||||||
|
u.ConfirmationToken = confirmationToken
|
||||||
|
}
|
||||||
|
ts.Require().NoError(err, "Error making new user")
|
||||||
|
ts.Require().NoError(ts.API.db.Create(u), "Error creating user")
|
||||||
|
|
||||||
|
if confirmationToken != "" {
|
||||||
|
ts.Require().NoError(models.CreateOneTimeToken(ts.API.db, u.ID, email, u.ConfirmationToken, models.ConfirmationToken), "Error creating one-time confirmation/invite token")
|
||||||
|
}
|
||||||
|
|
||||||
|
i, err := models.NewIdentity(u, "email", map[string]interface{}{
|
||||||
|
"sub": u.ID.String(),
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Require().NoError(ts.API.db.Create(i), "Error creating identity")
|
||||||
|
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func performAuthorizationRequest(ts *ExternalTestSuite, provider string, inviteToken string) *httptest.ResponseRecorder {
|
||||||
|
authorizeURL := "http://localhost/authorize?provider=" + provider
|
||||||
|
if inviteToken != "" {
|
||||||
|
authorizeURL = authorizeURL + "&invite_token=" + inviteToken
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, authorizeURL, nil)
|
||||||
|
req.Header.Set("Referer", "https://example.netlify.com/admin")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func performPKCEAuthorizationRequest(ts *ExternalTestSuite, provider, codeChallenge, codeChallengeMethod string) *httptest.ResponseRecorder {
|
||||||
|
authorizeURL := "http://localhost/authorize?provider=" + provider
|
||||||
|
if codeChallenge != "" {
|
||||||
|
authorizeURL = authorizeURL + "&code_challenge=" + codeChallenge + "&code_challenge_method=" + codeChallengeMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, authorizeURL, nil)
|
||||||
|
req.Header.Set("Referer", "https://example.supabase.com/admin")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func performPKCEAuthorization(ts *ExternalTestSuite, provider, code, codeChallenge, codeChallengeMethod string) *url.URL {
|
||||||
|
w := performPKCEAuthorizationRequest(ts, provider, codeChallenge, codeChallengeMethod)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
// Get code and state from the redirect
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
state := q.Get("state")
|
||||||
|
testURL, err := url.Parse("http://localhost/callback")
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
v := testURL.Query()
|
||||||
|
v.Set("code", code)
|
||||||
|
v.Set("state", state)
|
||||||
|
testURL.RawQuery = v.Encode()
|
||||||
|
// Use the code to get a token
|
||||||
|
req := httptest.NewRequest(http.MethodGet, testURL.String(), nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err = url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
|
||||||
|
return u
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func performAuthorization(ts *ExternalTestSuite, provider string, code string, inviteToken string) *url.URL {
|
||||||
|
w := performAuthorizationRequest(ts, provider, inviteToken)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
state := q.Get("state")
|
||||||
|
|
||||||
|
// auth server callback
|
||||||
|
testURL, err := url.Parse("http://localhost/callback")
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
v := testURL.Query()
|
||||||
|
v.Set("code", code)
|
||||||
|
v.Set("state", state)
|
||||||
|
testURL.RawQuery = v.Encode()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, testURL.String(), nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err = url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
ts.Require().Equal("/admin", u.Path)
|
||||||
|
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount int, userCount int, email string, name string, providerId string, avatar string) {
|
||||||
|
// ensure redirect has #access_token=...
|
||||||
|
v, err := url.ParseQuery(u.RawQuery)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Require().Empty(v.Get("error_description"))
|
||||||
|
ts.Require().Empty(v.Get("error"))
|
||||||
|
|
||||||
|
v, err = url.ParseQuery(u.Fragment)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.NotEmpty(v.Get("access_token"))
|
||||||
|
ts.NotEmpty(v.Get("refresh_token"))
|
||||||
|
ts.NotEmpty(v.Get("expires_in"))
|
||||||
|
ts.Equal("bearer", v.Get("token_type"))
|
||||||
|
|
||||||
|
ts.Equal(1, tokenCount)
|
||||||
|
if userCount > -1 {
|
||||||
|
ts.Equal(1, userCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure user has been created with metadata
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Equal(providerId, user.UserMetaData["provider_id"])
|
||||||
|
ts.Equal(name, user.UserMetaData["full_name"])
|
||||||
|
if avatar == "" {
|
||||||
|
ts.Equal(nil, user.UserMetaData["avatar_url"])
|
||||||
|
} else {
|
||||||
|
ts.Equal(avatar, user.UserMetaData["avatar_url"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAuthorizationFailure(ts *ExternalTestSuite, u *url.URL, errorDescription string, errorType string, email string) {
|
||||||
|
// ensure new sign ups error
|
||||||
|
v, err := url.ParseQuery(u.RawQuery)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Require().Equal(errorDescription, v.Get("error_description"))
|
||||||
|
ts.Require().Equal(errorType, v.Get("error"))
|
||||||
|
|
||||||
|
v, err = url.ParseQuery(u.Fragment)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Empty(v.Get("access_token"))
|
||||||
|
ts.Empty(v.Get("refresh_token"))
|
||||||
|
ts.Empty(v.Get("expires_in"))
|
||||||
|
ts.Empty(v.Get("token_type"))
|
||||||
|
|
||||||
|
// ensure user is nil
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud)
|
||||||
|
ts.Require().Error(err, "User not found")
|
||||||
|
ts.Require().Nil(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignupExternalUnsupported tests API /authorize for an unsupported external provider
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalUnsupported() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=external", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Equal(w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestRedirectErrorsShouldPreserveParams() {
|
||||||
|
// Request with invalid external provider
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=external", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
cases := []struct {
|
||||||
|
Desc string
|
||||||
|
RedirectURL string
|
||||||
|
QueryParams []string
|
||||||
|
ErrorMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Desc: "Should preserve redirect query params on error",
|
||||||
|
RedirectURL: "http://example.com/path?paramforpreservation=value2",
|
||||||
|
QueryParams: []string{"paramforpreservation"},
|
||||||
|
ErrorMessage: "invalid_request",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Desc: "Error param should be overwritten",
|
||||||
|
RedirectURL: "http://example.com/path?error=abc",
|
||||||
|
QueryParams: []string{"error"},
|
||||||
|
ErrorMessage: "invalid_request",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
parsedURL, err := url.Parse(c.RedirectURL)
|
||||||
|
require.Equal(ts.T(), err, nil)
|
||||||
|
|
||||||
|
redirectErrors(ts.API.internalExternalProviderCallback, w, req, parsedURL)
|
||||||
|
|
||||||
|
parsedParams, err := url.ParseQuery(parsedURL.RawQuery)
|
||||||
|
require.Equal(ts.T(), err, nil)
|
||||||
|
|
||||||
|
// An error and description should be returned
|
||||||
|
expectedQueryParams := append(c.QueryParams, "error", "error_description")
|
||||||
|
|
||||||
|
for _, expectedQueryParam := range expectedQueryParams {
|
||||||
|
val, exists := parsedParams[expectedQueryParam]
|
||||||
|
require.True(ts.T(), exists)
|
||||||
|
if expectedQueryParam == "error" {
|
||||||
|
require.Equal(ts.T(), val[0], c.ErrorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
twitchUser string = `{"data":[{"id":"twitchTestId","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
|
||||||
|
twitchUserWrongEmail string = `{"data":[{"id":"twitchTestId","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"other@example.com"}]}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalTwitch() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=twitch", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Twitch.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Twitch.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("user:read:email", q.Get("scope"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("twitch", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TwitchTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth2/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Twitch.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"Twitch_token","expires_in":100000}`)
|
||||||
|
case "/helix/users":
|
||||||
|
*userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown Twitch oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Twitch.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalTwitch_AuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, twitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "twitch", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch user", "twitchTestId", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "twitch", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "twitch@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":""}]}`
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "twitch", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "twitch@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("twitchTestId", "twitch@example.com", "Twitch user", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, twitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "twitch", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch user", "twitchTestId", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchSuccessWhenMatchingToken() {
|
||||||
|
// name and avatar should be populated from Twitch API
|
||||||
|
ts.createUser("twitchTestId", "twitch@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
TwitchUser := `{"data":[{"id":"twitchTestId","login":"Twitch Test","display_name":"Twitch Test","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "twitch", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "twitchTestId", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "twitch", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenWrongToken() {
|
||||||
|
ts.createUser("twitchTestId", "twitch@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, twitchUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "twitch", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("twitchTestId", "twitch@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, twitchUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "twitch", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalTwitter() {
|
||||||
|
server := TwitterTestSignupSetup(ts, nil, nil, "", "")
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=twitter", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
|
||||||
|
// Twitter uses OAuth1.0 protocol which only returns an oauth_token on the redirect
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal("twitter_oauth_token", q.Get("oauth_token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TwitterTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/request_token":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, "oauth_token=twitter_oauth_token&oauth_token_secret=twitter_oauth_token_secret&oauth_callback_confirmed=true")
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown google oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Twitter.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
workosUser string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","email":"workos@example.com","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}`
|
||||||
|
workosUserWrongEmail string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","email":"other@example.com","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}`
|
||||||
|
workosUserNoEmail string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithConnection() {
|
||||||
|
connection := "test_connection_id"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&connection=%s", connection), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("", q.Get("scope"))
|
||||||
|
ts.Equal(connection, q.Get("connection"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("workos", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithOrganization() {
|
||||||
|
organization := "test_organization_id"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&organization=%s", organization), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("", q.Get("scope"))
|
||||||
|
ts.Equal(organization, q.Get("organization"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("workos", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithProvider() {
|
||||||
|
provider := "test_provider"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&workos_provider=%s", provider), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
ts.Equal("", q.Get("scope"))
|
||||||
|
ts.Equal(provider, q.Get("provider"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("workos", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WorkosTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/sso/token":
|
||||||
|
// WorkOS returns the user data along with the token.
|
||||||
|
*tokenCount++
|
||||||
|
*userCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.WorkOS.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"access_token":"workos_token","expires_in":100000,"profile":%s}`, user)
|
||||||
|
default:
|
||||||
|
fmt.Printf("%s", r.URL.Path)
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown workos oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.WorkOS.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkosAuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "workos", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "workos", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "workos@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "workos", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "workos@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("test_prof_workos", "workos@example.com", "John Doe", "", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "workos", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosSuccessWhenMatchingToken() {
|
||||||
|
ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "workos", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "workos", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenWrongToken() {
|
||||||
|
ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "workos", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "workos", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
zoomUser string = `{"id":"zoomUserId","first_name":"John","last_name": "Doe","email": "zoom@example.com","verified": 1,"pic_url":"http://example.com/avatar"}`
|
||||||
|
zoomUserWrongEmail string = `{"id":"zoomUserId","first_name":"John","last_name": "Doe","email": "other@example.com","verified": 1,"pic_url":"http://example.com/avatar"}`
|
||||||
|
zoomUserNoEmail string = `{"id":"zoomUserId","first_name":"John","last_name": "Doe","verified": 1,"pic_url":"http://example.com/avatar"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalZoom() {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=zoom", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
ts.Equal(ts.Config.External.Zoom.RedirectURI, q.Get("redirect_uri"))
|
||||||
|
ts.Equal(ts.Config.External.Zoom.ClientID, []string{q.Get("client_id")})
|
||||||
|
ts.Equal("code", q.Get("response_type"))
|
||||||
|
|
||||||
|
claims := ExternalProviderClaims{}
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
|
||||||
|
ts.Equal("zoom", claims.Provider)
|
||||||
|
ts.Equal(ts.Config.SiteURL, claims.SiteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ZoomTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
*tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Zoom.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"zoom_token","expires_in":100000}`)
|
||||||
|
case "/v2/users/me":
|
||||||
|
*userCount++
|
||||||
|
ts.Contains(r.Header, "Authorization")
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, user)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown zoom oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ts.Config.External.Zoom.URL = server.URL
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalZoomAuthorizationCode() {
|
||||||
|
ts.Config.DisableSignup = false
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "zoom", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zoom@example.com", "John Doe", "zoomUserId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalZoomDisableSignupErrorWhenNoUser() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "zoom", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "zoom@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalZoomDisableSignupErrorWhenEmptyEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUserNoEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "zoom", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "zoom@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestSignupExternalZoomDisableSignupSuccessWithPrimaryEmail() {
|
||||||
|
ts.Config.DisableSignup = true
|
||||||
|
|
||||||
|
ts.createUser("zoomUserId", "zoom@example.com", "John Doe", "http://example.com/avatar", "")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "zoom", code, "")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zoom@example.com", "John Doe", "zoomUserId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalZoomSuccessWhenMatchingToken() {
|
||||||
|
ts.createUser("zoomUserId", "zoom@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "zoom", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zoom@example.com", "John Doe", "zoomUserId", "http://example.com/avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalZoomErrorWhenNoMatchingToken() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "zoom", "invite_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalZoomErrorWhenWrongToken() {
|
||||||
|
ts.createUser("zoomUserId", "zoom@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
w := performAuthorizationRequest(ts, "zoom", "wrong_token")
|
||||||
|
ts.Require().Equal(http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *ExternalTestSuite) TestInviteTokenExternalZoomErrorWhenEmailDoesntMatch() {
|
||||||
|
ts.createUser("zoomUserId", "zoom@example.com", "", "", "invite_token")
|
||||||
|
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUserWrongEmail)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
u := performAuthorization(ts, "zoom", code, "invite_token")
|
||||||
|
|
||||||
|
assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/security"
|
||||||
|
"github.com/supabase/auth/internal/utilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sendJSON(w http.ResponseWriter, status int, obj interface{}) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
b, err := json.Marshal(obj)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, fmt.Sprintf("Error encoding json response: %v", obj))
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, err = w.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdmin(u *models.User, config *conf.GlobalConfiguration) bool {
|
||||||
|
return config.JWT.Aud == u.Aud && u.HasRole(config.JWT.AdminGroupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) requestAud(ctx context.Context, r *http.Request) string {
|
||||||
|
config := a.config
|
||||||
|
// First check for an audience in the header
|
||||||
|
if aud := r.Header.Get(audHeaderName); aud != "" {
|
||||||
|
return aud
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check the token
|
||||||
|
claims := getClaims(ctx)
|
||||||
|
|
||||||
|
if claims != nil {
|
||||||
|
aud, _ := claims.GetAudience()
|
||||||
|
if len(aud) != 0 && aud[0] != "" {
|
||||||
|
return aud[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, return the default if none of the above methods are successful
|
||||||
|
return config.JWT.Aud
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStringInSlice(checkValue string, list []string) bool {
|
||||||
|
for _, val := range list {
|
||||||
|
if val == checkValue {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestParams interface {
|
||||||
|
AdminUserParams |
|
||||||
|
CreateSSOProviderParams |
|
||||||
|
EnrollFactorParams |
|
||||||
|
GenerateLinkParams |
|
||||||
|
IdTokenGrantParams |
|
||||||
|
InviteParams |
|
||||||
|
OtpParams |
|
||||||
|
PKCEGrantParams |
|
||||||
|
PasswordGrantParams |
|
||||||
|
RecoverParams |
|
||||||
|
RefreshTokenGrantParams |
|
||||||
|
ResendConfirmationParams |
|
||||||
|
SignupParams |
|
||||||
|
SingleSignOnParams |
|
||||||
|
SmsParams |
|
||||||
|
UserUpdateParams |
|
||||||
|
VerifyFactorParams |
|
||||||
|
VerifyParams |
|
||||||
|
adminUserUpdateFactorParams |
|
||||||
|
adminUserDeleteParams |
|
||||||
|
security.GotrueRequest |
|
||||||
|
ChallengeFactorParams |
|
||||||
|
struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
} |
|
||||||
|
struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieveRequestParams is a generic method that unmarshals the request body into the params struct provided
|
||||||
|
func retrieveRequestParams[A RequestParams](r *http.Request, params *A) error {
|
||||||
|
body, err := utilities.GetBodyBytes(r)
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Could not read body into byte slice").WithInternalError(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, params); err != nil {
|
||||||
|
return badRequestError(ErrorCodeBadJSON, "Could not parse request body as JSON: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsValidCodeChallenge(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
challenge string
|
||||||
|
isValid bool
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
challenge: "invalid",
|
||||||
|
isValid: false,
|
||||||
|
expectedError: badRequestError(ErrorCodeValidationFailed, "code challenge has to be between %v and %v characters", MinCodeChallengeLength, MaxCodeChallengeLength),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
challenge: "codechallengecontainsinvalidcharacterslike@$^&*",
|
||||||
|
isValid: false,
|
||||||
|
expectedError: badRequestError(ErrorCodeValidationFailed, "code challenge can only contain alphanumeric characters, hyphens, periods, underscores and tildes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
challenge: "validchallengevalidchallengevalidchallengevalidchallenge",
|
||||||
|
isValid: true,
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
valid, err := isValidCodeChallenge(c.challenge)
|
||||||
|
require.Equal(t, c.isValid, valid)
|
||||||
|
require.Equal(t, c.expectedError, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidPKCEParams(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
challengeMethod string
|
||||||
|
challenge string
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
challengeMethod: "",
|
||||||
|
challenge: "",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
challengeMethod: "test",
|
||||||
|
challenge: "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
challengeMethod: "test",
|
||||||
|
challenge: "",
|
||||||
|
expected: badRequestError(ErrorCodeValidationFailed, InvalidPKCEParamsErrorMessage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
challengeMethod: "",
|
||||||
|
challenge: "test",
|
||||||
|
expected: badRequestError(ErrorCodeValidationFailed, InvalidPKCEParamsErrorMessage),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, c := range cases {
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
err := validatePKCEParams(c.challengeMethod, c.challenge)
|
||||||
|
require.Equal(t, c.expected, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestAud(ts *testing.T) {
|
||||||
|
mockAPI := API{
|
||||||
|
config: &conf.GlobalConfiguration{
|
||||||
|
JWT: conf.JWTConfiguration{
|
||||||
|
Aud: "authenticated",
|
||||||
|
Secret: "test-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
headers map[string]string
|
||||||
|
payload map[string]interface{}
|
||||||
|
expectedAud string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "Valid audience slice",
|
||||||
|
headers: map[string]string{
|
||||||
|
audHeaderName: "my_custom_aud",
|
||||||
|
},
|
||||||
|
payload: map[string]interface{}{
|
||||||
|
"aud": "authenticated",
|
||||||
|
},
|
||||||
|
expectedAud: "my_custom_aud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Valid custom audience",
|
||||||
|
payload: map[string]interface{}{
|
||||||
|
"aud": "my_custom_aud",
|
||||||
|
},
|
||||||
|
expectedAud: "my_custom_aud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Invalid audience",
|
||||||
|
payload: map[string]interface{}{
|
||||||
|
"aud": "",
|
||||||
|
},
|
||||||
|
expectedAud: mockAPI.config.JWT.Aud,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Missing audience",
|
||||||
|
payload: map[string]interface{}{
|
||||||
|
"sub": "d6044b6e-b0ec-4efe-a055-0d2d6ff1dbd8",
|
||||||
|
},
|
||||||
|
expectedAud: mockAPI.config.JWT.Aud,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims(c.payload)
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString([]byte(mockAPI.config.JWT.Secret))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer: %s", signed))
|
||||||
|
for k, v := range c.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the token in the request context for requestAud
|
||||||
|
ctx, err := mockAPI.parseJWTClaims(signed, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
aud := mockAPI.requestAud(ctx, req)
|
||||||
|
require.Equal(t, c.expectedAud, aud)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
|
||||||
|
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/hooks"
|
||||||
|
"github.com/supabase/auth/internal/observability"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultHTTPHookTimeout = 5 * time.Second
|
||||||
|
DefaultHTTPHookRetries = 3
|
||||||
|
HTTPHookBackoffDuration = 2 * time.Second
|
||||||
|
PayloadLimit = 200 * 1024 // 200KB
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) runPostgresHook(ctx context.Context, tx *storage.Connection, hookConfig conf.ExtensibilityPointConfiguration, input, output any) ([]byte, error) {
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
|
||||||
|
request, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response []byte
|
||||||
|
invokeHookFunc := func(tx *storage.Connection) error {
|
||||||
|
// We rely on Postgres timeouts to ensure the function doesn't overrun
|
||||||
|
if terr := tx.RawQuery(fmt.Sprintf("set local statement_timeout TO '%d';", hooks.DefaultTimeout)).Exec(); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := tx.RawQuery(fmt.Sprintf("select %s(?);", hookConfig.HookName), request).First(&response); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset the timeout
|
||||||
|
if terr := tx.RawQuery("set local statement_timeout TO default;").Exec(); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx != nil {
|
||||||
|
if err := invokeHookFunc(tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := db.Transaction(invokeHookFunc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(response, output); err != nil {
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) runHTTPHook(r *http.Request, hookConfig conf.ExtensibilityPointConfiguration, input any) ([]byte, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: DefaultHTTPHookTimeout,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultHTTPHookTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log := observability.GetLogEntry(r).Entry
|
||||||
|
requestURL := hookConfig.URI
|
||||||
|
hookLog := log.WithFields(logrus.Fields{
|
||||||
|
"component": "auth_hook",
|
||||||
|
"url": requestURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
inputPayload, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := 0; i < DefaultHTTPHookRetries; i++ {
|
||||||
|
if i == 0 {
|
||||||
|
hookLog.Debugf("invocation attempt: %d", i)
|
||||||
|
} else {
|
||||||
|
hookLog.Infof("invocation attempt: %d", i)
|
||||||
|
}
|
||||||
|
msgID := uuid.Must(uuid.NewV4())
|
||||||
|
currentTime := time.Now()
|
||||||
|
signatureList, err := generateSignatures(hookConfig.HTTPHookSecrets, msgID, currentTime, inputPayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(inputPayload))
|
||||||
|
if err != nil {
|
||||||
|
panic("Failed to make request object")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("webhook-id", msgID.String())
|
||||||
|
req.Header.Set("webhook-timestamp", fmt.Sprintf("%d", currentTime.Unix()))
|
||||||
|
req.Header.Set("webhook-signature", strings.Join(signatureList, ", "))
|
||||||
|
// By default, Go Client sets encoding to gzip, which does not carry a content length header.
|
||||||
|
req.Header.Set("Accept-Encoding", "identity")
|
||||||
|
|
||||||
|
rsp, err := client.Do(req)
|
||||||
|
if err != nil && errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeHookTimeout, fmt.Sprintf("Failed to reach hook within maximum time of %f seconds", DefaultHTTPHookTimeout.Seconds()))
|
||||||
|
|
||||||
|
} else if err != nil {
|
||||||
|
if terr, ok := err.(net.Error); ok && terr.Timeout() || i < DefaultHTTPHookRetries-1 {
|
||||||
|
hookLog.Errorf("Request timed out for attempt %d with err %s", i, err)
|
||||||
|
time.Sleep(HTTPHookBackoffDuration)
|
||||||
|
continue
|
||||||
|
} else if i == DefaultHTTPHookRetries-1 {
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeHookTimeoutAfterRetry, "Failed to reach hook after maximum retries")
|
||||||
|
} else {
|
||||||
|
return nil, internalServerError("Failed to trigger auth hook, error making HTTP request").WithInternalError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rsp.Body.Close()
|
||||||
|
|
||||||
|
switch rsp.StatusCode {
|
||||||
|
case http.StatusOK, http.StatusNoContent, http.StatusAccepted:
|
||||||
|
// Header.Get is case insensitive
|
||||||
|
contentType := rsp.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
return nil, badRequestError(ErrorCodeHookPayloadInvalidContentType, "Invalid Content-Type: Missing Content-Type header")
|
||||||
|
}
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, badRequestError(ErrorCodeHookPayloadInvalidContentType, fmt.Sprintf("Invalid Content-Type header: %s", err.Error()))
|
||||||
|
}
|
||||||
|
if mediaType != "application/json" {
|
||||||
|
return nil, badRequestError(ErrorCodeHookPayloadInvalidContentType, "Invalid JSON response. Received content-type: "+contentType)
|
||||||
|
}
|
||||||
|
if rsp.Body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
limitedReader := io.LimitedReader{R: rsp.Body, N: PayloadLimit}
|
||||||
|
body, err := io.ReadAll(&limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limitedReader.N <= 0 {
|
||||||
|
// check if the response body still has excess bytes to be read
|
||||||
|
if n, _ := rsp.Body.Read(make([]byte, 1)); n > 0 {
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeHookPayloadOverSizeLimit, fmt.Sprintf("Payload size exceeded size limit of %d bytes", PayloadLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
case http.StatusTooManyRequests, http.StatusServiceUnavailable:
|
||||||
|
retryAfterHeader := rsp.Header.Get("retry-after")
|
||||||
|
// Check for truthy values to allow for flexibility to switch to time duration
|
||||||
|
if retryAfterHeader != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, internalServerError("Service currently unavailable due to hook")
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
return nil, internalServerError("Invalid payload sent to hook")
|
||||||
|
case http.StatusUnauthorized:
|
||||||
|
return nil, internalServerError("Hook requires authorization token")
|
||||||
|
default:
|
||||||
|
return nil, internalServerError("Unexpected status code returned from hook: %d", rsp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invokePostgresHook invokes the hook code. conn can be nil, in which case a new
|
||||||
|
// transaction is opened. If calling invokeHook within a transaction, always
|
||||||
|
// pass the current transaction, as pool-exhaustion deadlocks are very easy to
|
||||||
|
// trigger.
|
||||||
|
func (a *API) invokeHook(conn *storage.Connection, r *http.Request, input, output any) error {
|
||||||
|
var err error
|
||||||
|
var response []byte
|
||||||
|
|
||||||
|
switch input.(type) {
|
||||||
|
case *hooks.SendSMSInput:
|
||||||
|
hookOutput, ok := output.(*hooks.SendSMSOutput)
|
||||||
|
if !ok {
|
||||||
|
panic("output should be *hooks.SendSMSOutput")
|
||||||
|
}
|
||||||
|
if response, err = a.runHook(r, conn, a.config.Hook.SendSMS, input, output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response, hookOutput); err != nil {
|
||||||
|
return internalServerError("Error unmarshaling Send SMS output.").WithInternalError(err)
|
||||||
|
}
|
||||||
|
if hookOutput.IsError() {
|
||||||
|
httpCode := hookOutput.HookError.HTTPCode
|
||||||
|
|
||||||
|
if httpCode == 0 {
|
||||||
|
httpCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
httpError := &HTTPError{
|
||||||
|
HTTPStatus: httpCode,
|
||||||
|
Message: hookOutput.HookError.Message,
|
||||||
|
}
|
||||||
|
return httpError.WithInternalError(&hookOutput.HookError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case *hooks.SendEmailInput:
|
||||||
|
hookOutput, ok := output.(*hooks.SendEmailOutput)
|
||||||
|
if !ok {
|
||||||
|
panic("output should be *hooks.SendEmailOutput")
|
||||||
|
}
|
||||||
|
if response, err = a.runHook(r, conn, a.config.Hook.SendEmail, input, output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response, hookOutput); err != nil {
|
||||||
|
return internalServerError("Error unmarshaling Send Email output.").WithInternalError(err)
|
||||||
|
}
|
||||||
|
if hookOutput.IsError() {
|
||||||
|
httpCode := hookOutput.HookError.HTTPCode
|
||||||
|
|
||||||
|
if httpCode == 0 {
|
||||||
|
httpCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
httpError := &HTTPError{
|
||||||
|
HTTPStatus: httpCode,
|
||||||
|
Message: hookOutput.HookError.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpError.WithInternalError(&hookOutput.HookError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case *hooks.MFAVerificationAttemptInput:
|
||||||
|
hookOutput, ok := output.(*hooks.MFAVerificationAttemptOutput)
|
||||||
|
if !ok {
|
||||||
|
panic("output should be *hooks.MFAVerificationAttemptOutput")
|
||||||
|
}
|
||||||
|
if response, err = a.runHook(r, conn, a.config.Hook.MFAVerificationAttempt, input, output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response, hookOutput); err != nil {
|
||||||
|
return internalServerError("Error unmarshaling MFA Verification Attempt output.").WithInternalError(err)
|
||||||
|
}
|
||||||
|
if hookOutput.IsError() {
|
||||||
|
httpCode := hookOutput.HookError.HTTPCode
|
||||||
|
|
||||||
|
if httpCode == 0 {
|
||||||
|
httpCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
httpError := &HTTPError{
|
||||||
|
HTTPStatus: httpCode,
|
||||||
|
Message: hookOutput.HookError.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpError.WithInternalError(&hookOutput.HookError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case *hooks.PasswordVerificationAttemptInput:
|
||||||
|
hookOutput, ok := output.(*hooks.PasswordVerificationAttemptOutput)
|
||||||
|
if !ok {
|
||||||
|
panic("output should be *hooks.PasswordVerificationAttemptOutput")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response, err = a.runHook(r, conn, a.config.Hook.PasswordVerificationAttempt, input, output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response, hookOutput); err != nil {
|
||||||
|
return internalServerError("Error unmarshaling Password Verification Attempt output.").WithInternalError(err)
|
||||||
|
}
|
||||||
|
if hookOutput.IsError() {
|
||||||
|
httpCode := hookOutput.HookError.HTTPCode
|
||||||
|
|
||||||
|
if httpCode == 0 {
|
||||||
|
httpCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
httpError := &HTTPError{
|
||||||
|
HTTPStatus: httpCode,
|
||||||
|
Message: hookOutput.HookError.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpError.WithInternalError(&hookOutput.HookError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case *hooks.CustomAccessTokenInput:
|
||||||
|
hookOutput, ok := output.(*hooks.CustomAccessTokenOutput)
|
||||||
|
if !ok {
|
||||||
|
panic("output should be *hooks.CustomAccessTokenOutput")
|
||||||
|
}
|
||||||
|
if response, err = a.runHook(r, conn, a.config.Hook.CustomAccessToken, input, output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response, hookOutput); err != nil {
|
||||||
|
return internalServerError("Error unmarshaling Custom Access Token output.").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hookOutput.IsError() {
|
||||||
|
httpCode := hookOutput.HookError.HTTPCode
|
||||||
|
|
||||||
|
if httpCode == 0 {
|
||||||
|
httpCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
httpError := &HTTPError{
|
||||||
|
HTTPStatus: httpCode,
|
||||||
|
Message: hookOutput.HookError.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpError.WithInternalError(&hookOutput.HookError)
|
||||||
|
}
|
||||||
|
if err := validateTokenClaims(hookOutput.Claims); err != nil {
|
||||||
|
httpCode := hookOutput.HookError.HTTPCode
|
||||||
|
|
||||||
|
if httpCode == 0 {
|
||||||
|
httpCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
httpError := &HTTPError{
|
||||||
|
HTTPStatus: httpCode,
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) runHook(r *http.Request, conn *storage.Connection, hookConfig conf.ExtensibilityPointConfiguration, input, output any) ([]byte, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
logEntry := observability.GetLogEntry(r)
|
||||||
|
hookStart := time.Now()
|
||||||
|
|
||||||
|
var response []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(hookConfig.URI, "http:") || strings.HasPrefix(hookConfig.URI, "https:"):
|
||||||
|
response, err = a.runHTTPHook(r, hookConfig, input)
|
||||||
|
case strings.HasPrefix(hookConfig.URI, "pg-functions:"):
|
||||||
|
response, err = a.runPostgresHook(ctx, conn, hookConfig, input, output)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported protocol: %q only postgres hooks and HTTPS functions are supported at the moment", hookConfig.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(hookStart)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logEntry.Entry.WithFields(logrus.Fields{
|
||||||
|
"action": "run_hook",
|
||||||
|
"hook": hookConfig.URI,
|
||||||
|
"success": false,
|
||||||
|
"duration": duration.Microseconds(),
|
||||||
|
}).WithError(err).Warn("Hook errored out")
|
||||||
|
|
||||||
|
return nil, internalServerError("Error running hook URI: %v", hookConfig.URI).WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logEntry.Entry.WithFields(logrus.Fields{
|
||||||
|
"action": "run_hook",
|
||||||
|
"hook": hookConfig.URI,
|
||||||
|
"success": true,
|
||||||
|
"duration": duration.Microseconds(),
|
||||||
|
}).WithError(err).Info("Hook ran successfully")
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSignatures(secrets []string, msgID uuid.UUID, currentTime time.Time, inputPayload []byte) ([]string, error) {
|
||||||
|
SymmetricSignaturePrefix := "v1,"
|
||||||
|
// TODO(joel): Handle asymmetric case once library has been upgraded
|
||||||
|
var signatureList []string
|
||||||
|
for _, secret := range secrets {
|
||||||
|
if strings.HasPrefix(secret, SymmetricSignaturePrefix) {
|
||||||
|
trimmedSecret := strings.TrimPrefix(secret, SymmetricSignaturePrefix)
|
||||||
|
wh, err := standardwebhooks.NewWebhook(trimmedSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signature, err := wh.Sign(msgID.String(), currentTime, inputPayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signatureList = append(signatureList, signature)
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("invalid signature format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return signatureList, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/hooks"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
|
||||||
|
"gopkg.in/h2non/gock.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var handleApiRequest func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
type HooksTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
TestUser *models.User
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockHttpClient struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
return handleApiRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHooks(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &HooksTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *HooksTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
u, err := models.NewUser("123456789", "testemail@gmail.com", "securetestpassword", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error creating test user model")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
|
||||||
|
ts.TestUser = u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *HooksTestSuite) TestRunHTTPHook() {
|
||||||
|
// setup mock requests for hooks
|
||||||
|
defer gock.OffAll()
|
||||||
|
|
||||||
|
input := hooks.SendSMSInput{
|
||||||
|
User: ts.TestUser,
|
||||||
|
SMS: hooks.SMS{
|
||||||
|
OTP: "123456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testURL := "http://localhost:54321/functions/v1/custom-sms-sender"
|
||||||
|
ts.Config.Hook.SendSMS.URI = testURL
|
||||||
|
|
||||||
|
unsuccessfulResponse := hooks.AuthHookError{
|
||||||
|
HTTPCode: http.StatusUnprocessableEntity,
|
||||||
|
Message: "test error",
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
description string
|
||||||
|
expectError bool
|
||||||
|
mockResponse hooks.AuthHookError
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "Hook returns success",
|
||||||
|
expectError: false,
|
||||||
|
mockResponse: hooks.AuthHookError{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Hook returns error",
|
||||||
|
expectError: true,
|
||||||
|
mockResponse: unsuccessfulResponse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gock.New(ts.Config.Hook.SendSMS.URI).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusOK).
|
||||||
|
JSON(hooks.SendSMSOutput{})
|
||||||
|
|
||||||
|
gock.New(ts.Config.Hook.SendSMS.URI).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusUnprocessableEntity).
|
||||||
|
JSON(hooks.SendSMSOutput{HookError: unsuccessfulResponse})
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
ts.Run(tc.description, func() {
|
||||||
|
req, _ := http.NewRequest("POST", ts.Config.Hook.SendSMS.URI, nil)
|
||||||
|
body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input)
|
||||||
|
|
||||||
|
if !tc.expectError {
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
} else {
|
||||||
|
require.Error(ts.T(), err)
|
||||||
|
if body != nil {
|
||||||
|
var output hooks.SendSMSOutput
|
||||||
|
require.NoError(ts.T(), json.Unmarshal(body, &output))
|
||||||
|
require.Equal(ts.T(), unsuccessfulResponse.HTTPCode, output.HookError.HTTPCode)
|
||||||
|
require.Equal(ts.T(), unsuccessfulResponse.Message, output.HookError.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
require.True(ts.T(), gock.IsDone())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *HooksTestSuite) TestShouldRetryWithRetryAfterHeader() {
|
||||||
|
defer gock.OffAll()
|
||||||
|
|
||||||
|
input := hooks.SendSMSInput{
|
||||||
|
User: ts.TestUser,
|
||||||
|
SMS: hooks.SMS{
|
||||||
|
OTP: "123456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testURL := "http://localhost:54321/functions/v1/custom-sms-sender"
|
||||||
|
ts.Config.Hook.SendSMS.URI = testURL
|
||||||
|
|
||||||
|
gock.New(testURL).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusTooManyRequests).
|
||||||
|
SetHeader("retry-after", "true").SetHeader("content-type", "application/json")
|
||||||
|
|
||||||
|
// Simulate an additional response for the retry attempt
|
||||||
|
gock.New(testURL).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusOK).
|
||||||
|
JSON(hooks.SendSMSOutput{}).SetHeader("content-type", "application/json")
|
||||||
|
|
||||||
|
// Simulate the original HTTP request which triggered the hook
|
||||||
|
req, err := http.NewRequest("POST", "http://localhost:9998/otp", nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
body, err := ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
var output hooks.SendSMSOutput
|
||||||
|
err = json.Unmarshal(body, &output)
|
||||||
|
require.NoError(ts.T(), err, "Unmarshal should not fail")
|
||||||
|
|
||||||
|
// Ensure that all expected HTTP interactions (mocks) have been called
|
||||||
|
require.True(ts.T(), gock.IsDone(), "Expected all mocks to have been called including retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *HooksTestSuite) TestShouldReturnErrorForNonJSONContentType() {
|
||||||
|
defer gock.OffAll()
|
||||||
|
|
||||||
|
input := hooks.SendSMSInput{
|
||||||
|
User: ts.TestUser,
|
||||||
|
SMS: hooks.SMS{
|
||||||
|
OTP: "123456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testURL := "http://localhost:54321/functions/v1/custom-sms-sender"
|
||||||
|
ts.Config.Hook.SendSMS.URI = testURL
|
||||||
|
|
||||||
|
gock.New(testURL).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusOK).
|
||||||
|
SetHeader("content-type", "text/plain")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "http://localhost:9999/otp", nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
_, err = ts.API.runHTTPHook(req, ts.Config.Hook.SendSMS, &input)
|
||||||
|
require.Error(ts.T(), err, "Expected an error due to wrong content type")
|
||||||
|
require.Contains(ts.T(), err.Error(), "Invalid JSON response.")
|
||||||
|
|
||||||
|
require.True(ts.T(), gock.IsDone(), "Expected all mocks to have been called")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *HooksTestSuite) TestInvokeHookIntegration() {
|
||||||
|
// We use the Send Email Hook as illustration
|
||||||
|
defer gock.OffAll()
|
||||||
|
hookFunctionSQL := `
|
||||||
|
create or replace function invoke_test(input jsonb)
|
||||||
|
returns json as $$
|
||||||
|
begin
|
||||||
|
return input;
|
||||||
|
end; $$ language plpgsql;`
|
||||||
|
require.NoError(ts.T(), ts.API.db.RawQuery(hookFunctionSQL).Exec())
|
||||||
|
|
||||||
|
testHTTPUri := "http://myauthservice.com/signup"
|
||||||
|
testHTTPSUri := "https://myauthservice.com/signup"
|
||||||
|
testPGUri := "pg-functions://postgres/auth/invoke_test"
|
||||||
|
successOutput := map[string]interface{}{}
|
||||||
|
authEndpoint := "https://app.myapp.com/otp"
|
||||||
|
gock.New(testHTTPUri).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusOK).
|
||||||
|
JSON(successOutput).SetHeader("content-type", "application/json")
|
||||||
|
|
||||||
|
gock.New(testHTTPSUri).
|
||||||
|
Post("/").
|
||||||
|
MatchType("json").
|
||||||
|
Reply(http.StatusOK).
|
||||||
|
JSON(successOutput).SetHeader("content-type", "application/json")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
description string
|
||||||
|
conn *storage.Connection
|
||||||
|
request *http.Request
|
||||||
|
input any
|
||||||
|
output any
|
||||||
|
uri string
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "HTTP endpoint success",
|
||||||
|
conn: nil,
|
||||||
|
request: httptest.NewRequest("POST", authEndpoint, nil),
|
||||||
|
input: &hooks.SendEmailInput{},
|
||||||
|
output: &hooks.SendEmailOutput{},
|
||||||
|
uri: testHTTPUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "HTTPS endpoint success",
|
||||||
|
conn: nil,
|
||||||
|
request: httptest.NewRequest("POST", authEndpoint, nil),
|
||||||
|
input: &hooks.SendEmailInput{},
|
||||||
|
output: &hooks.SendEmailOutput{},
|
||||||
|
uri: testHTTPSUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "PostgreSQL function success",
|
||||||
|
conn: ts.API.db,
|
||||||
|
request: httptest.NewRequest("POST", authEndpoint, nil),
|
||||||
|
input: &hooks.SendEmailInput{},
|
||||||
|
output: &hooks.SendEmailOutput{},
|
||||||
|
uri: testPGUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Unsupported protocol error",
|
||||||
|
conn: nil,
|
||||||
|
request: httptest.NewRequest("POST", authEndpoint, nil),
|
||||||
|
input: &hooks.SendEmailInput{},
|
||||||
|
output: &hooks.SendEmailOutput{},
|
||||||
|
uri: "ftp://example.com/path",
|
||||||
|
expectedError: errors.New("unsupported protocol: \"ftp://example.com/path\" only postgres hooks and HTTPS functions are supported at the moment"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
for _, tc := range tests {
|
||||||
|
// Set up hook config
|
||||||
|
ts.Config.Hook.SendEmail.Enabled = true
|
||||||
|
ts.Config.Hook.SendEmail.URI = tc.uri
|
||||||
|
require.NoError(ts.T(), ts.Config.Hook.SendEmail.PopulateExtensibilityPoint())
|
||||||
|
|
||||||
|
ts.Run(tc.description, func() {
|
||||||
|
err = ts.API.invokeHook(tc.conn, tc.request, tc.input, tc.output)
|
||||||
|
if tc.expectedError != nil {
|
||||||
|
require.EqualError(ts.T(), err, tc.expectedError.Error())
|
||||||
|
} else {
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
// Ensure that all expected HTTP interactions (mocks) have been called
|
||||||
|
require.True(ts.T(), gock.IsDone(), "Expected all mocks to have been called including retry")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims := getClaims(ctx)
|
||||||
|
if claims == nil {
|
||||||
|
return internalServerError("Could not read claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
identityID, err := uuid.FromString(chi.URLParam(r, "identity_id"))
|
||||||
|
if err != nil {
|
||||||
|
return notFoundError(ErrorCodeValidationFailed, "identity_id must be an UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
aud := a.requestAud(ctx, r)
|
||||||
|
audienceFromClaims, _ := claims.GetAudience()
|
||||||
|
if len(audienceFromClaims) == 0 || aud != audienceFromClaims[0] {
|
||||||
|
return forbiddenError(ErrorCodeUnexpectedAudience, "Token audience doesn't match request audience")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := getUser(ctx)
|
||||||
|
if len(user.Identities) <= 1 {
|
||||||
|
return unprocessableEntityError(ErrorCodeSingleIdentityNotDeletable, "User must have at least 1 identity after unlinking")
|
||||||
|
}
|
||||||
|
var identityToBeDeleted *models.Identity
|
||||||
|
for i := range user.Identities {
|
||||||
|
identity := user.Identities[i]
|
||||||
|
if identity.ID == identityID {
|
||||||
|
identityToBeDeleted = &identity
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identityToBeDeleted == nil {
|
||||||
|
return unprocessableEntityError(ErrorCodeIdentityNotFound, "Identity doesn't exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, user, models.IdentityUnlinkAction, "", map[string]interface{}{
|
||||||
|
"identity_id": identityToBeDeleted.ID,
|
||||||
|
"provider": identityToBeDeleted.Provider,
|
||||||
|
"provider_id": identityToBeDeleted.ProviderID,
|
||||||
|
}); terr != nil {
|
||||||
|
return internalServerError("Error recording audit log entry").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
if terr := tx.Destroy(identityToBeDeleted); terr != nil {
|
||||||
|
return internalServerError("Database error deleting identity").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch identityToBeDeleted.Provider {
|
||||||
|
case "phone":
|
||||||
|
user.PhoneConfirmedAt = nil
|
||||||
|
if terr := user.SetPhone(tx, ""); terr != nil {
|
||||||
|
return internalServerError("Database error updating user phone").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
if terr := tx.UpdateOnly(user, "phone_confirmed_at"); terr != nil {
|
||||||
|
return internalServerError("Database error updating user phone").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if terr := user.UpdateUserEmailFromIdentities(tx); terr != nil {
|
||||||
|
if models.IsUniqueConstraintViolatedError(terr) {
|
||||||
|
return unprocessableEntityError(ErrorCodeEmailConflictIdentityNotDeletable, "Unable to unlink identity due to email conflict").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
return internalServerError("Database error updating user email").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
|
||||||
|
return internalServerError("Database error updating user providers").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, map[string]interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) LinkIdentity(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
user := getUser(ctx)
|
||||||
|
rurl, err := a.GetExternalProviderRedirectURL(w, r, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skipHTTPRedirect := r.URL.Query().Get("skip_http_redirect") == "true"
|
||||||
|
if skipHTTPRedirect {
|
||||||
|
return sendJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"url": rurl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, rurl, http.StatusFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) linkIdentityToUser(r *http.Request, ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
|
||||||
|
targetUser := getTargetUser(ctx)
|
||||||
|
identity, terr := models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType)
|
||||||
|
if terr != nil {
|
||||||
|
if !models.IsNotFoundError(terr) {
|
||||||
|
return nil, internalServerError("Database error finding identity for linking").WithInternalError(terr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identity != nil {
|
||||||
|
if identity.UserID == targetUser.ID {
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeIdentityAlreadyExists, "Identity is already linked")
|
||||||
|
}
|
||||||
|
return nil, unprocessableEntityError(ErrorCodeIdentityAlreadyExists, "Identity is already linked to another user")
|
||||||
|
}
|
||||||
|
if _, terr := a.createNewIdentity(tx, targetUser, providerType, structs.Map(userData.Metadata)); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetUser.GetEmail() == "" {
|
||||||
|
if terr := targetUser.UpdateUserEmailFromIdentities(tx); terr != nil {
|
||||||
|
if models.IsUniqueConstraintViolatedError(terr) {
|
||||||
|
return nil, badRequestError(ErrorCodeEmailExists, DuplicateEmailMsg)
|
||||||
|
}
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
if !userData.Metadata.EmailVerified {
|
||||||
|
if terr := a.sendConfirmation(r, tx, targetUser, models.ImplicitFlow); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
return nil, storage.NewCommitWithError(unprocessableEntityError(ErrorCodeEmailNotConfirmed, "Unverified email with %v. A confirmation email has been sent to your %v email", providerType, providerType))
|
||||||
|
}
|
||||||
|
if terr := targetUser.Confirm(tx); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetUser.IsAnonymous {
|
||||||
|
targetUser.IsAnonymous = false
|
||||||
|
if terr := tx.UpdateOnly(targetUser, "is_anonymous"); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := targetUser.UpdateAppMetaDataProviders(tx); terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
return targetUser, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IdentityTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
ts := &IdentityTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *IdentityTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
u, err := models.NewUser("", "one@example.com", "password", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error creating test user model")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
|
||||||
|
require.NoError(ts.T(), u.Confirm(ts.API.db))
|
||||||
|
|
||||||
|
// Create identity
|
||||||
|
i, err := models.NewIdentity(u, "email", map[string]interface{}{
|
||||||
|
"sub": u.ID.String(),
|
||||||
|
"email": u.GetEmail(),
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(i))
|
||||||
|
|
||||||
|
// Create user with 2 identities
|
||||||
|
u, err = models.NewUser("123456789", "two@example.com", "password", ts.Config.JWT.Aud, nil)
|
||||||
|
require.NoError(ts.T(), err, "Error creating test user model")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
|
||||||
|
require.NoError(ts.T(), u.Confirm(ts.API.db))
|
||||||
|
require.NoError(ts.T(), u.ConfirmPhone(ts.API.db))
|
||||||
|
|
||||||
|
i, err = models.NewIdentity(u, "email", map[string]interface{}{
|
||||||
|
"sub": u.ID.String(),
|
||||||
|
"email": u.GetEmail(),
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(i))
|
||||||
|
|
||||||
|
i2, err := models.NewIdentity(u, "phone", map[string]interface{}{
|
||||||
|
"sub": u.ID.String(),
|
||||||
|
"phone": u.GetPhone(),
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(i2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *IdentityTestSuite) TestLinkIdentityToUser() {
|
||||||
|
u, err := models.FindUserByEmailAndAudience(ts.API.db, "one@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
ctx := withTargetUser(context.Background(), u)
|
||||||
|
|
||||||
|
// link a valid identity
|
||||||
|
testValidUserData := &provider.UserProvidedData{
|
||||||
|
Metadata: &provider.Claims{
|
||||||
|
Subject: "test_subject",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// request is just used as a placeholder in the function
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/identities", nil)
|
||||||
|
u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "test")
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// load associated identities for the user
|
||||||
|
ts.API.db.Load(u, "Identities")
|
||||||
|
require.Len(ts.T(), u.Identities, 2)
|
||||||
|
require.Equal(ts.T(), u.AppMetaData["provider"], "email")
|
||||||
|
require.Equal(ts.T(), u.AppMetaData["providers"], []string{"email", "test"})
|
||||||
|
|
||||||
|
// link an already existing identity
|
||||||
|
testExistingUserData := &provider.UserProvidedData{
|
||||||
|
Metadata: &provider.Claims{
|
||||||
|
Subject: u.ID.String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testExistingUserData, "email")
|
||||||
|
require.ErrorIs(ts.T(), err, unprocessableEntityError(ErrorCodeIdentityAlreadyExists, "Identity is already linked"))
|
||||||
|
require.Nil(ts.T(), u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *IdentityTestSuite) TestUnlinkIdentityError() {
|
||||||
|
ts.Config.Security.ManualLinkingEnabled = true
|
||||||
|
userWithOneIdentity, err := models.FindUserByEmailAndAudience(ts.API.db, "one@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
userWithTwoIdentities, err := models.FindUserByEmailAndAudience(ts.API.db, "two@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
user *models.User
|
||||||
|
identityId uuid.UUID
|
||||||
|
expectedError *HTTPError
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "User must have at least 1 identity after unlinking",
|
||||||
|
user: userWithOneIdentity,
|
||||||
|
identityId: userWithOneIdentity.Identities[0].ID,
|
||||||
|
expectedError: unprocessableEntityError(ErrorCodeSingleIdentityNotDeletable, "User must have at least 1 identity after unlinking"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Identity doesn't exist",
|
||||||
|
user: userWithTwoIdentities,
|
||||||
|
identityId: uuid.Must(uuid.NewV4()),
|
||||||
|
expectedError: unprocessableEntityError(ErrorCodeIdentityNotFound, "Identity doesn't exist"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
token := ts.generateAccessTokenAndSession(c.user)
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", c.identityId), nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), c.expectedError.HTTPStatus, w.Code)
|
||||||
|
|
||||||
|
var data HTTPError
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.Equal(ts.T(), c.expectedError.Message, data.Message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *IdentityTestSuite) TestUnlinkIdentity() {
|
||||||
|
ts.Config.Security.ManualLinkingEnabled = true
|
||||||
|
|
||||||
|
// we want to test 2 cases here: unlinking a phone identity and email identity from a user
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
// the provider to be unlinked
|
||||||
|
provider string
|
||||||
|
// the remaining provider that should be linked to the user
|
||||||
|
providerRemaining string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "Unlink phone identity successfully",
|
||||||
|
provider: "phone",
|
||||||
|
providerRemaining: "email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Unlink email identity successfully",
|
||||||
|
provider: "email",
|
||||||
|
providerRemaining: "phone",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
// teardown and reset the state of the db to prevent running into errors
|
||||||
|
ts.SetupTest()
|
||||||
|
u, err := models.FindUserByEmailAndAudience(ts.API.db, "two@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), c.provider)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
token := ts.generateAccessTokenAndSession(u)
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// sanity checks
|
||||||
|
u, err = models.FindUserByID(ts.API.db, u.ID)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.Len(ts.T(), u.Identities, 1)
|
||||||
|
require.Equal(ts.T(), u.Identities[0].Provider, c.providerRemaining)
|
||||||
|
|
||||||
|
// conditional checks depending on the provider that was unlinked
|
||||||
|
switch c.provider {
|
||||||
|
case "phone":
|
||||||
|
require.Equal(ts.T(), "", u.GetPhone())
|
||||||
|
require.Nil(ts.T(), u.PhoneConfirmedAt)
|
||||||
|
case "email":
|
||||||
|
require.Equal(ts.T(), "", u.GetEmail())
|
||||||
|
require.Nil(ts.T(), u.EmailConfirmedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// user still has a phone / email identity linked so it should not be unconfirmed
|
||||||
|
require.NotNil(ts.T(), u.ConfirmedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *IdentityTestSuite) generateAccessTokenAndSession(u *models.User) string {
|
||||||
|
s, err := models.NewSession(u.ID, nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(s))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil)
|
||||||
|
token, _, err := ts.API.generateAccessToken(req, ts.API.db, u, &s.ID, models.PasswordGrant)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
return token
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
"github.com/supabase/auth/internal/api/provider"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InviteParams are the parameters the Signup endpoint accepts
|
||||||
|
type InviteParams struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite is the endpoint for inviting a new user
|
||||||
|
func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
adminUser := getAdminUser(ctx)
|
||||||
|
params := &InviteParams{}
|
||||||
|
if err := retrieveRequestParams(r, params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
params.Email, err = a.validateEmail(params.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aud := a.requestAud(ctx, r)
|
||||||
|
user, err := models.FindUserByEmailAndAudience(db, params.Email, aud)
|
||||||
|
if err != nil && !models.IsNotFoundError(err) {
|
||||||
|
return internalServerError("Database error finding user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if user != nil {
|
||||||
|
if user.IsConfirmed() {
|
||||||
|
return unprocessableEntityError(ErrorCodeEmailExists, DuplicateEmailMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signupParams := SignupParams{
|
||||||
|
Email: params.Email,
|
||||||
|
Data: params.Data,
|
||||||
|
Aud: aud,
|
||||||
|
Provider: "email",
|
||||||
|
}
|
||||||
|
|
||||||
|
// because params above sets no password, this method
|
||||||
|
// is not computationally hard so it can be used within
|
||||||
|
// a database transaction
|
||||||
|
user, err = signupParams.ToUserModel(false /* <- isSSOUser */)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = a.signupNewUser(tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity, err := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
|
||||||
|
Subject: user.ID.String(),
|
||||||
|
Email: user.GetEmail(),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Identities = []models.Identity{*identity}
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UserInvitedAction, "", map[string]interface{}{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"user_email": user.Email,
|
||||||
|
}); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.sendInvite(r, tx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
"github.com/supabase/auth/internal/crypto"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InviteTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
API *API
|
||||||
|
Config *conf.GlobalConfiguration
|
||||||
|
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvite(t *testing.T) {
|
||||||
|
api, config, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ts := &InviteTestSuite{
|
||||||
|
API: api,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
defer api.db.Close()
|
||||||
|
|
||||||
|
suite.Run(t, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) SetupTest() {
|
||||||
|
models.TruncateAll(ts.API.db)
|
||||||
|
|
||||||
|
// Setup response recorder with super admin privileges
|
||||||
|
ts.token = ts.makeSuperAdmin("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) makeSuperAdmin(email string) string {
|
||||||
|
// Cleanup existing user, if they already exist
|
||||||
|
if u, _ := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud); u != nil {
|
||||||
|
require.NoError(ts.T(), ts.API.db.Destroy(u), "Error deleting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := models.NewUser("123456789", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"})
|
||||||
|
require.NoError(ts.T(), err, "Error making new user")
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(u))
|
||||||
|
|
||||||
|
u.Role = "supabase_admin"
|
||||||
|
|
||||||
|
var token string
|
||||||
|
|
||||||
|
session, err := models.NewSession(u.ID, nil)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(session))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/invite", nil)
|
||||||
|
token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.Invite)
|
||||||
|
|
||||||
|
require.NoError(ts.T(), err, "Error generating access token")
|
||||||
|
|
||||||
|
p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
_, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ts.Config.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
require.NoError(ts.T(), err, "Error parsing token")
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) TestInvite() {
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"a": 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
// Setup response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) TestInviteAfterSignupShouldNotReturnSensitiveFields() {
|
||||||
|
// To allow us to send signup and invite request in succession
|
||||||
|
ts.Config.SMTP.MaxFrequency = 5
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"a": 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
// Setup response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "test123",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"a": 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/signup", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Setup response recorder
|
||||||
|
x := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(x, req)
|
||||||
|
|
||||||
|
require.Equal(ts.T(), http.StatusOK, x.Code)
|
||||||
|
|
||||||
|
data := models.User{}
|
||||||
|
require.NoError(ts.T(), json.NewDecoder(x.Body).Decode(&data))
|
||||||
|
// Sensitive fields
|
||||||
|
require.Equal(ts.T(), 0, len(data.Identities))
|
||||||
|
require.Equal(ts.T(), 0, len(data.UserMetaData))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) TestInvite_WithoutAccess() {
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"a": 1,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Setup response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
assert.Equal(ts.T(), http.StatusUnauthorized, w.Code) // 401 OK because the invite request above has no Authorization header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) TestVerifyInvite() {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
email string
|
||||||
|
requestBody map[string]interface{}
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"Verify invite with password",
|
||||||
|
"test@example.com",
|
||||||
|
map[string]interface{}{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"type": "invite",
|
||||||
|
"token": "asdf",
|
||||||
|
"password": "testing",
|
||||||
|
},
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Verify invite with no password",
|
||||||
|
"test1@example.com",
|
||||||
|
map[string]interface{}{
|
||||||
|
"email": "test1@example.com",
|
||||||
|
"type": "invite",
|
||||||
|
"token": "asdf",
|
||||||
|
},
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ts.Run(c.desc, func() {
|
||||||
|
user, err := models.NewUser("", c.email, "", ts.Config.JWT.Aud, nil)
|
||||||
|
now := time.Now()
|
||||||
|
user.InvitedAt = &now
|
||||||
|
user.ConfirmationSentAt = &now
|
||||||
|
user.EncryptedPassword = nil
|
||||||
|
user.ConfirmationToken = crypto.GenerateTokenHash(c.email, c.requestBody["token"].(string))
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
require.NoError(ts.T(), ts.API.db.Create(user))
|
||||||
|
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, user.ID, user.GetEmail(), user.ConfirmationToken, models.ConfirmationToken))
|
||||||
|
|
||||||
|
// Find test user
|
||||||
|
_, err = models.FindUserByEmailAndAudience(ts.API.db, c.email, ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.requestBody))
|
||||||
|
|
||||||
|
// Setup request
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Setup response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(ts.T(), c.expected, w.Code, w.Body.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) TestInviteExternalGitlab() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Gitlab.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"gitlab_token","expires_in":100000}`)
|
||||||
|
case "/api/v4/user":
|
||||||
|
userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"name":"Gitlab Test","email":"gitlab@example.com","avatar_url":"http://example.com/avatar","confirmed_at": "2020-01-01T00:00:00.000Z"}`)
|
||||||
|
case "/api/v4/user/emails":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
ts.Fail("unknown gitlab oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
ts.Config.External.Gitlab.URL = server.URL
|
||||||
|
|
||||||
|
// invite user
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(InviteParams{
|
||||||
|
Email: "gitlab@example.com",
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Find test user
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "gitlab@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// get redirect url w/ state
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=gitlab&invite_token="+user.ConfirmationToken, nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
state := q.Get("state")
|
||||||
|
|
||||||
|
// auth server callback
|
||||||
|
testURL, err := url.Parse("http://localhost/callback")
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
v := testURL.Query()
|
||||||
|
v.Set("code", code)
|
||||||
|
v.Set("state", state)
|
||||||
|
testURL.RawQuery = v.Encode()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, testURL.String(), nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err = url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
|
||||||
|
// ensure redirect has #access_token=...
|
||||||
|
v, err = url.ParseQuery(u.Fragment)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Require().Empty(v.Get("error_description"))
|
||||||
|
ts.Require().Empty(v.Get("error"))
|
||||||
|
|
||||||
|
ts.NotEmpty(v.Get("access_token"))
|
||||||
|
ts.NotEmpty(v.Get("refresh_token"))
|
||||||
|
ts.NotEmpty(v.Get("expires_in"))
|
||||||
|
ts.Equal("bearer", v.Get("token_type"))
|
||||||
|
|
||||||
|
ts.Equal(1, tokenCount)
|
||||||
|
ts.Equal(1, userCount)
|
||||||
|
|
||||||
|
// ensure user has been created with metadata
|
||||||
|
user, err = models.FindUserByEmailAndAudience(ts.API.db, "gitlab@example.com", ts.Config.JWT.Aud)
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
ts.Equal("Gitlab Test", user.UserMetaData["full_name"])
|
||||||
|
ts.Equal("http://example.com/avatar", user.UserMetaData["avatar_url"])
|
||||||
|
ts.Equal("gitlab", user.AppMetaData["provider"])
|
||||||
|
ts.Equal([]interface{}{"gitlab"}, user.AppMetaData["providers"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *InviteTestSuite) TestInviteExternalGitlab_MismatchedEmails() {
|
||||||
|
tokenCount, userCount := 0, 0
|
||||||
|
code := "authcode"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/oauth/token":
|
||||||
|
tokenCount++
|
||||||
|
ts.Equal(code, r.FormValue("code"))
|
||||||
|
ts.Equal("authorization_code", r.FormValue("grant_type"))
|
||||||
|
ts.Equal(ts.Config.External.Gitlab.RedirectURI, r.FormValue("redirect_uri"))
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"access_token":"gitlab_token","expires_in":100000}`)
|
||||||
|
case "/api/v4/user":
|
||||||
|
userCount++
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"name":"Gitlab Test","email":"gitlab+mismatch@example.com","avatar_url":"http://example.com/avatar","confirmed_at": "2020-01-01T00:00:00.000Z"}`)
|
||||||
|
case "/api/v4/user/emails":
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(500)
|
||||||
|
ts.Fail("unknown gitlab oauth call %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
ts.Config.External.Gitlab.URL = server.URL
|
||||||
|
|
||||||
|
// invite user
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(InviteParams{
|
||||||
|
Email: "gitlab@example.com",
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/invite", &buffer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Find test user
|
||||||
|
user, err := models.FindUserByEmailAndAudience(ts.API.db, "gitlab@example.com", ts.Config.JWT.Aud)
|
||||||
|
require.NoError(ts.T(), err)
|
||||||
|
|
||||||
|
// get redirect url w/ state
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=gitlab&invite_token="+user.ConfirmationToken, nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err := url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
q := u.Query()
|
||||||
|
state := q.Get("state")
|
||||||
|
|
||||||
|
// auth server callback
|
||||||
|
testURL, err := url.Parse("http://localhost/callback")
|
||||||
|
ts.Require().NoError(err)
|
||||||
|
v := testURL.Query()
|
||||||
|
v.Set("code", code)
|
||||||
|
v.Set("state", state)
|
||||||
|
testURL.RawQuery = v.Encode()
|
||||||
|
req = httptest.NewRequest(http.MethodGet, testURL.String(), nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
ts.API.handler.ServeHTTP(w, req)
|
||||||
|
ts.Require().Equal(http.StatusFound, w.Code)
|
||||||
|
u, err = url.Parse(w.Header().Get("Location"))
|
||||||
|
ts.Require().NoError(err, "redirect url parse failed")
|
||||||
|
|
||||||
|
// ensure redirect has #access_token=...
|
||||||
|
v, err = url.ParseQuery(u.RawQuery)
|
||||||
|
ts.Require().NoError(err, u.RawQuery)
|
||||||
|
ts.Require().NotEmpty(v.Get("error_description"))
|
||||||
|
ts.Require().Equal("invalid_request", v.Get("error"))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
|
jwk "github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JwksResponse struct {
|
||||||
|
Keys []jwk.Key `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Jwks(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
config := a.config
|
||||||
|
resp := JwksResponse{
|
||||||
|
Keys: []jwk.Key{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range config.JWT.Keys {
|
||||||
|
// don't expose hmac jwk in endpoint
|
||||||
|
if key.PublicKey == nil || key.PublicKey.KeyType() == jwa.OctetSeq {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Keys = append(resp.Keys, key.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||||
|
return sendJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signJwt(config *conf.JWTConfiguration, claims jwt.Claims) (string, error) {
|
||||||
|
signingJwk, err := conf.GetSigningJwk(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
signingMethod := conf.GetSigningAlg(signingJwk)
|
||||||
|
token := jwt.NewWithClaims(signingMethod, claims)
|
||||||
|
if token.Header == nil {
|
||||||
|
token.Header = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := token.Header["kid"]; !ok {
|
||||||
|
if kid := signingJwk.KeyID(); kid != "" {
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// this serializes the aud claim to a string
|
||||||
|
jwt.MarshalSingleStringAsArray = false
|
||||||
|
signingKey, err := conf.GetSigningKey(signingJwk)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
signed, err := token.SignedString(signingKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/supabase/auth/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJwks(t *testing.T) {
|
||||||
|
// generate RSA key pair for testing
|
||||||
|
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rsaJwkPrivate, err := jwk.FromRaw(rsaPrivateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rsaJwkPublic, err := rsaJwkPrivate.PublicKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
kid := rsaJwkPublic.KeyID()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
config conf.JWTConfiguration
|
||||||
|
expectedLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "hmac key should not be returned",
|
||||||
|
config: conf.JWTConfiguration{
|
||||||
|
Aud: "authenticated",
|
||||||
|
Secret: "test-secret",
|
||||||
|
},
|
||||||
|
expectedLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "rsa public key returned",
|
||||||
|
config: conf.JWTConfiguration{
|
||||||
|
Aud: "authenticated",
|
||||||
|
Secret: "test-secret",
|
||||||
|
Keys: conf.JwtKeysDecoder{
|
||||||
|
kid: conf.JwkInfo{
|
||||||
|
PublicKey: rsaJwkPublic,
|
||||||
|
PrivateKey: rsaJwkPrivate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.desc, func(t *testing.T) {
|
||||||
|
mockAPI, _, err := setupAPIForTest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
mockAPI.config.JWT = c.config
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mockAPI.handler.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&data))
|
||||||
|
require.Len(t, data["keys"], c.expectedLen)
|
||||||
|
|
||||||
|
for _, key := range data["keys"].([]interface{}) {
|
||||||
|
bytes, err := json.Marshal(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
actualKey, err := jwk.ParseKey(bytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, c.config.Keys[kid].PublicKey, actualKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/supabase/auth/internal/models"
|
||||||
|
"github.com/supabase/auth/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogoutBehavior string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LogoutGlobal LogoutBehavior = "global"
|
||||||
|
LogoutLocal LogoutBehavior = "local"
|
||||||
|
LogoutOthers LogoutBehavior = "others"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logout is the endpoint for logging out a user and thereby revoking any refresh tokens
|
||||||
|
func (a *API) Logout(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
db := a.db.WithContext(ctx)
|
||||||
|
scope := LogoutGlobal
|
||||||
|
|
||||||
|
if r.URL.Query() != nil {
|
||||||
|
switch r.URL.Query().Get("scope") {
|
||||||
|
case "", "global":
|
||||||
|
scope = LogoutGlobal
|
||||||
|
|
||||||
|
case "local":
|
||||||
|
scope = LogoutLocal
|
||||||
|
|
||||||
|
case "others":
|
||||||
|
scope = LogoutOthers
|
||||||
|
|
||||||
|
default:
|
||||||
|
return badRequestError(ErrorCodeValidationFailed, fmt.Sprintf("Unsupported logout scope %q", r.URL.Query().Get("scope")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getSession(ctx)
|
||||||
|
u := getUser(ctx)
|
||||||
|
|
||||||
|
err := db.Transaction(func(tx *storage.Connection) error {
|
||||||
|
if terr := models.NewAuditLogEntry(r, tx, u, models.LogoutAction, "", nil); terr != nil {
|
||||||
|
return terr
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
logrus.Infof("user has an empty session_id claim: %s", u.ID)
|
||||||
|
} else {
|
||||||
|
//exhaustive:ignore Default case is handled below.
|
||||||
|
switch scope {
|
||||||
|
case LogoutLocal:
|
||||||
|
return models.LogoutSession(tx, s.ID)
|
||||||
|
|
||||||
|
case LogoutOthers:
|
||||||
|
return models.LogoutAllExceptMe(tx, s.ID, u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// default mode, log out everywhere
|
||||||
|
return models.Logout(tx, u.ID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return internalServerError("Error logging out user").WithInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue