Introduce application tokens

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2023-02-15 19:31:23 +01:00
parent 39768693c5
commit 2ee329ff7b
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
30 changed files with 1533 additions and 32 deletions

View file

@ -13,4 +13,20 @@ B9AF8A342CD7FF39E10CC10A408C28E1
C042E87389F7BDCFF4E076E95731AE69 C042E87389F7BDCFF4E076E95731AE69
C42BFAEF7100F57BED75998B217C857A C42BFAEF7100F57BED75998B217C857A
D11958E86F1B6D37EF656B63405CA8A4 D11958E86F1B6D37EF656B63405CA8A4
F16F054F2628609A726B9FF2F089D484 F16F054F2628609A726B9FF2F089D484
26E816A7B054CB0347A2C6451F03B92B
2B76BDDB2BB4D36D69FAE793EBD63894
301A837DE24C6AEE1DA812DF9E5486C1
395A2740CB468F93F6EBE6E90EE08291
4013C9866943B9381D9F9F97027F88A9
4C796DD588A4B1C98E86BBCD0349949A
51289D8D7BDB59CB6473E0DED0591ED7
5A70DC86895DB3610C605EA9F31ED300
705C17F9C852F546D886B20DB2C4D0D1
75D2074B6F771BA8C032008EC18CABDF
7B1C6E35A374C38FF5F07DBF23B3EAE2
955ACF52ADD8FCAA450FB8138CB1FD1A
A092A563729E1F2C1C8D5D809A31F754
BFA12FDEDEAD7DEAB6D44DF6FDFBD5E1
D9A08930F140F9BA494BB90B3F812C87
FE1EEB91EA633570F703B251AE2D4D4E

View file

@ -17,6 +17,10 @@
:title="t('Notifications')" :title="t('Notifications')"
:to="{ name: RouteName.NOTIFICATIONS }" :to="{ name: RouteName.NOTIFICATIONS }"
/> />
<SettingMenuItem
:title="t('Apps')"
:to="{ name: RouteName.AUTHORIZED_APPS }"
/>
</SettingMenuSection> </SettingMenuSection>
<SettingMenuSection <SettingMenuSection
:title="t('Profiles')" :title="t('Profiles')"

View file

@ -0,0 +1,55 @@
import gql from "graphql-tag";
export const AUTH_APPLICATION = gql`
query AuthApplication($clientId: String!) {
authApplication(clientId: $clientId) {
clientId
name
website
}
}
`;
export const AUTORIZE_APPLICATION = gql`
mutation AuthorizeApplication(
$applicationClientId: String!
$redirectURI: String!
$state: String
$scope: String
) {
authorizeApplication(
clientId: $applicationClientId
redirectURI: $redirectURI
state: $state
scope: $scope
) {
code
state
}
}
`;
export const AUTH_AUTHORIZED_APPLICATIONS = gql`
query AuthAuthorizedApplications {
loggedUser {
id
authAuthorizedApplications {
id
application {
name
website
}
lastUsedAt
insertedAt
}
}
}
`;
export const REVOKED_AUTHORIZED_APPLICATION = gql`
mutation RevokeApplicationToken($appTokenId: String!) {
revokeApplicationToken(appTokenId: $appTokenId) {
id
}
}
`;

View file

@ -1453,5 +1453,9 @@
"Report as ham": "Report as ham", "Report as ham": "Report as ham",
"Report as undetected spam": "Report as undetected spam", "Report as undetected spam": "Report as undetected spam",
"The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.": "The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.", "The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.": "The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.",
"Submit to Akismet": "Submit to Akismet" "Submit to Akismet": "Submit to Akismet",
"Autorize this application to access your account?": "Autorize this application to access your account?",
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.": "This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.",
"Authorize application": "Authorize application",
"Authorize": "Authorize"
} }

View file

@ -1451,5 +1451,9 @@
"{username} was invited to {group}": "{username} a été invité à {group}", "{username} was invited to {group}": "{username} a été invité à {group}",
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée", "{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée", "{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Autorize this application to access your account?": "Autoriser cette application à accéder à votre compte ?",
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.": "Cette application sera capable d'accéder à toutes vos informations et poster du contenu en votre nom. Assurez-vous d'approuver uniquement des applications en lesquelles vous avez confiance.",
"Authorize application": "Autoriser l'application",
"Authorize": "Autoriser"
} }

View file

@ -27,6 +27,7 @@ export enum SettingsRouteName {
CREATE_IDENTITY = "CreateIdentity", CREATE_IDENTITY = "CreateIdentity",
UPDATE_IDENTITY = "UpdateIdentity", UPDATE_IDENTITY = "UpdateIdentity",
IDENTITIES = "IDENTITIES", IDENTITIES = "IDENTITIES",
AUTHORIZED_APPS = "AUTHORIZED_APPS",
} }
export const settingsRoutes: RouteRecordRaw[] = [ export const settingsRoutes: RouteRecordRaw[] = [
@ -84,6 +85,18 @@ export const settingsRoutes: RouteRecordRaw[] = [
}, },
}, },
}, },
{
path: "authorized-apps",
name: SettingsRouteName.AUTHORIZED_APPS,
component: (): Promise<any> => import("@/views/Settings/AppsView.vue"),
props: true,
meta: {
requiredAuth: true,
announcer: {
message: (): string => t("Apps") as string,
},
},
},
{ {
path: "admin", path: "admin",
name: SettingsRouteName.ADMIN, name: SettingsRouteName.ADMIN,

View file

@ -13,6 +13,7 @@ export enum UserRouteName {
EMAIL_VALIDATE = "EMAIL_VALIDATE", EMAIL_VALIDATE = "EMAIL_VALIDATE",
VALIDATE = "Validate", VALIDATE = "Validate",
LOGIN = "Login", LOGIN = "Login",
OAUTH_AUTORIZE = "OAUTH_AUTORIZE",
} }
export const userRoutes: RouteRecordRaw[] = [ export const userRoutes: RouteRecordRaw[] = [
@ -108,4 +109,15 @@ export const userRoutes: RouteRecordRaw[] = [
announcer: { message: (): string => t("Login") as string }, announcer: { message: (): string => t("Login") as string },
}, },
}, },
{
path: "/oauth/autorize_approve",
name: UserRouteName.OAUTH_AUTORIZE,
component: (): Promise<any> => import("@/views/OAuth/AuthorizeView.vue"),
meta: {
requiredAuth: true,
announcer: {
message: (): string => t("Authorize application") as string,
},
},
},
]; ];

View file

@ -0,0 +1,15 @@
export interface IApplication {
name: string;
clientId: string;
clientSecret?: string;
redirectUris?: string;
scopes: string | null;
website: string | null;
}
export interface IApplicationToken {
id: string;
application: IApplication;
lastUsedAt: string;
insertedAt: string;
}

View file

@ -7,6 +7,7 @@ import { IFollowedGroupEvent } from "./followedGroupEvent.model";
import { PictureInformation } from "./picture"; import { PictureInformation } from "./picture";
import { IMember } from "./actor/member.model"; import { IMember } from "./actor/member.model";
import { IFeedToken } from "./feedtoken.model"; import { IFeedToken } from "./feedtoken.model";
import { IApplicationToken } from "./application.model";
export interface ICurrentUser { export interface ICurrentUser {
id: string; id: string;
@ -66,4 +67,5 @@ export interface IUser extends ICurrentUser {
currentSignInAt: string; currentSignInAt: string;
memberships: Paginate<IMember>; memberships: Paginate<IMember>;
feedTokens: IFeedToken[]; feedTokens: IFeedToken[];
authAuthorizedApplications: IApplicationToken[];
} }

View file

@ -0,0 +1,191 @@
<template>
<div class="container mx-auto w-96">
<div v-show="authApplicationLoading && !resultCode">
<o-skeleton active size="large" class="mt-6" />
<o-skeleton active width="80%" />
<div
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
>
<div>
<o-skeleton circle active width="42px" height="42px" />
</div>
<div class="w-full">
<o-skeleton active />
<o-skeleton active />
<o-skeleton active />
</div>
</div>
<div class="rounded-lg bg-white shadow-xl my-6">
<div class="p-4 pb-0">
<p class="text-3xl"><o-skeleton active size="large" /></p>
<o-skeleton active width="40%" />
</div>
<div class="flex gap-3 p-4">
<o-skeleton active />
<o-skeleton active />
</div>
</div>
</div>
<div
v-show="!authApplicationLoading && !authApplicationError && !resultCode"
>
<h1 class="text-3xl">
{{ t("Autorize this application to access your account?") }}
</h1>
<div
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
>
<AlertCircle :size="42" />
<p>
{{
t(
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust."
)
}}
</p>
</div>
<div class="rounded-lg bg-white shadow-xl my-6">
<div class="p-4 pb-0">
<p class="text-3xl font-bold">{{ authApplication?.name }}</p>
<p>{{ authApplication?.website }}</p>
</div>
<div class="flex gap-3 p-4">
<o-button @click="() => authorize()">{{ t("Authorize") }}</o-button>
<o-button outlined tag="router-link" :to="{ name: RouteName.HOME }">{{
t("Decline")
}}</o-button>
</div>
</div>
</div>
<div v-show="authApplicationError">
<div
class="rounded-lg bg-mbz-danger shadow-xl my-6 p-4 flex items-center gap-2"
v-if="authApplicationGraphError?.status_code === 404"
>
<AlertCircle :size="42" />
<div>
<p class="font-bold">
{{ t("Application not found") }}
</p>
<p>{{ t("The provided application was not found.") }}</p>
</div>
</div>
<o-button
variant="text"
tag="router-link"
:to="{ name: RouteName.HOME }"
>{{ t("Back to homepage") }}</o-button
>
</div>
<div
v-if="resultCode"
class="rounded-lg bg-white shadow-xl my-6 p-4 flex items-center gap-2"
>
<div>
<p class="font-bold">
{{ t("Your application code") }}
</p>
<p>
{{
t(
"You need to provide the following code to your application. It will only be valid for a few minutes."
)
}}
</p>
<p class="text-4xl">{{ resultCode }}</p>
</div>
</div>
<o-button variant="text" tag="router-link" :to="{ name: RouteName.HOME }">{{
t("Back to homepage")
}}</o-button>
</div>
</template>
<script lang="ts" setup>
import { useRouteQuery } from "vue-use-route-query";
import { useHead } from "@vueuse/head";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { AUTH_APPLICATION, AUTORIZE_APPLICATION } from "@/graphql/application";
import { IApplication } from "@/types/application.model";
import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
import type { AbsintheGraphQLError } from "@/types/errors.model";
import RouteName from "@/router/name";
const { t } = useI18n({ useScope: "global" });
const clientId = useRouteQuery("client_id", null);
const redirectURI = useRouteQuery("redirect_uri", null);
const state = useRouteQuery("state", null);
const scope = useRouteQuery("scope", null);
const OUT_OF_BAND_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
const resultCode = ref<string | null>(null);
const {
result: authApplicationResult,
loading: authApplicationLoading,
error: authApplicationError,
} = useQuery<{ authApplication: IApplication }, { clientId: string }>(
AUTH_APPLICATION,
() => ({
clientId: clientId.value as string,
}),
() => ({
enabled: clientId.value !== null,
})
);
const authApplication = computed(
() => authApplicationResult.value?.authApplication
);
const authApplicationGraphError = computed(
() => authApplicationError.value?.graphQLErrors[0] as AbsintheGraphQLError
);
const { mutate: authorizeMutation, onDone: onAuthorizeMutationDone } =
useMutation<
{ authorizeApplication: { code: string; state: string } },
{
applicationClientId: string;
redirectURI: string;
state?: string | null;
scope?: string | null;
}
>(AUTORIZE_APPLICATION);
const authorize = () => {
authorizeMutation({
applicationClientId: clientId.value as string,
redirectURI: redirectURI.value as string,
state: state.value,
scope: scope.value,
});
};
onAuthorizeMutationDone(({ data }) => {
const code = data?.authorizeApplication?.code;
const returnedState = data?.authorizeApplication?.state ?? "";
if (!code) return;
if (redirectURI.value !== OUT_OF_BAND_REDIRECT_URI) {
const params = new URLSearchParams(
Object.entries({ code, state: returnedState })
);
window.location.assign(
new URL(`${redirectURI.value}?${params.toString()}`)
);
return;
}
resultCode.value = code;
});
useHead({
title: computed(() => t("Authorize application")),
});
</script>

View file

@ -0,0 +1,138 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.AUTHORIZED_APPS,
text: t('Apps'),
},
{
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
text: t('General'),
},
]"
/>
<section>
<h2>{{ t("Apps") }}</h2>
<p>
{{
t(
"These apps can access your account through the API. If you see here apps that you don't recognize, that don't work as expected or that you don't use anymore, you can revoke their access."
)
}}
</p>
<div
class="flex justify-between items-center rounded-lg bg-white shadow-xl my-6"
v-for="authAuthorizedApplication in authAuthorizedApplications"
:key="authAuthorizedApplication.id"
>
<div class="p-4">
<p class="text-3xl font-bold">
{{ authAuthorizedApplication.application.name }}
</p>
<a
v-if="authAuthorizedApplication.application.website"
target="_blank"
:href="authAuthorizedApplication.application.website"
>{{
urlToHostname(authAuthorizedApplication.application.website)
}}</a
>
<p>
<span v-if="authAuthorizedApplication.lastUsedAt">{{
t("Last used on {last_used_date}", {
last_used_date: formatDateString(
authAuthorizedApplication.lastUsedAt
),
})
}}</span>
<span v-else>{{ t("Never used") }}</span>
{{
t("Authorized on {authorization_date}", {
authorization_date: formatDateString(
authAuthorizedApplication.insertedAt
),
})
}}
</p>
</div>
<div class="p-4">
<o-button
@click="() => revoke({ appTokenId: authAuthorizedApplication.id })"
variant="danger"
>{{ t("Revoke") }}</o-button
>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { useLoggedUser } from "@/composition/apollo/user";
import {
AUTH_AUTHORIZED_APPLICATIONS,
REVOKED_AUTHORIZED_APPLICATION,
} from "@/graphql/application";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import RouteName from "../../router/name";
import { IUser } from "@/types/current-user.model";
import { formatDateString } from "@/filters/datetime";
const { t } = useI18n({ useScope: "global" });
const { loggedUser } = useLoggedUser();
const { result: authAuthorizedApplicationsResult } = useQuery<{
loggedUser: Pick<IUser, "authAuthorizedApplications">;
}>(AUTH_AUTHORIZED_APPLICATIONS);
const authAuthorizedApplications = computed(
() =>
authAuthorizedApplicationsResult.value?.loggedUser
?.authAuthorizedApplications
);
const urlToHostname = (url: string | undefined): string | null => {
if (!url) return null;
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
};
const { mutate: revoke, onDone: onRevokedApplication } = useMutation<
{ revokeApplicationToken: { id: string } },
{ appTokenId: string }
>(REVOKED_AUTHORIZED_APPLICATION, {
update: (cache, { data: returnedData }) => {
const data = cache.readQuery<{
loggedUser: Pick<IUser, "authAuthorizedApplications">;
}>({ query: AUTH_AUTHORIZED_APPLICATIONS });
if (!data) return;
if (!returnedData) return;
const authorizedApplications =
data.loggedUser.authAuthorizedApplications.filter(
(app) => app.id !== returnedData.revokeApplicationToken.id
);
cache.writeQuery({
query: AUTH_AUTHORIZED_APPLICATIONS,
data: {
...data,
loggedUser: {
...data.loggedUser,
authAuthorizedApplications: authorizedApplications,
},
},
});
},
});
useHead({
title: computed(() => t("Apps")),
});
</script>

View file

@ -0,0 +1,92 @@
defmodule Mobilizon.GraphQL.Resolvers.Application do
@moduledoc """
Handles the Application-related GraphQL calls.
"""
alias Mobilizon.Applications, as: ApplicationManager
alias Mobilizon.Applications.{Application, ApplicationToken}
alias Mobilizon.Service.Auth.Applications
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext, only: [dgettext: 2]
require Logger
@doc """
Create an application
"""
@spec authorize(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()} | {:error, String.t()}
def authorize(
_parent,
%{client_id: client_id, redirect_uri: redirect_uri, scope: scope, state: state},
%{context: %{current_user: %User{id: user_id}}}
) do
case Applications.autorize(client_id, redirect_uri, scope, user_id) do
{:ok, code} ->
{:ok, %{code: code, state: state}}
{:error, :application_not_found} ->
{:error,
dgettext(
"errors",
"No application with this client_id was found"
)}
{:error, :redirect_uri_not_in_allowed} ->
{:error,
dgettext(
"errors",
"The given redirect_uri is not in the list of allowed redirect URIs"
)}
end
end
def authorize(_parent, _args, _context) do
{:error, dgettext("errors", "You need to be logged-in to autorize applications")}
end
@spec get_application(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Application.t()} | {:error, :not_found | :unauthenticated}
def get_application(_parent, %{client_id: client_id}, %{context: %{current_user: %User{}}}) do
case ApplicationManager.get_application_by_client_id(client_id) do
%Application{} = application ->
{:ok, application}
nil ->
{:error, :not_found}
end
end
def get_application(_parent, _args, _resolution) do
{:error, :unauthenticated}
end
def get_user_applications(_parent, _args, %{context: %{current_user: %User{id: user_id}}}) do
{:ok, ApplicationManager.list_application_tokens_for_user_id(user_id)}
end
def get_user_applications(_parent, _args, _resolution) do
{:error, :unauthenticated}
end
def revoke_application_token(_parent, %{app_token_id: app_token_id}, %{
context: %{current_user: %User{id: user_id}}
}) do
case ApplicationManager.get_application_token(app_token_id) do
%ApplicationToken{user_id: ^user_id} = app_token ->
case Applications.revoke_application_token(app_token) do
{:ok, %{delete_app_token: app_token, delete_guardian_tokens: _delete_guardian_tokens}} ->
{:ok, %{id: app_token.id}}
{:error, _, _, _} ->
{:error, dgettext("errors", "Error while revoking token")}
end
_ ->
{:error, :not_found}
end
end
def revoke_application_token(_parent, _args, _resolution) do
{:error, :unauthenticated}
end
end

View file

@ -53,6 +53,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Users.PushSubscription) import_types(Schema.Users.PushSubscription)
import_types(Schema.Users.ActivitySetting) import_types(Schema.Users.ActivitySetting)
import_types(Schema.FollowedGroupActivityType) import_types(Schema.FollowedGroupActivityType)
import_types(Schema.AuthApplicationType)
@desc "A struct containing the id of the deleted object" @desc "A struct containing the id of the deleted object"
object :deleted_object do object :deleted_object do
@ -161,6 +162,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:resource_queries) import_fields(:resource_queries)
import_fields(:post_queries) import_fields(:post_queries)
import_fields(:statistics_queries) import_fields(:statistics_queries)
import_fields(:auth_application_queries)
end end
@desc """ @desc """
@ -187,6 +189,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:follower_mutations) import_fields(:follower_mutations)
import_fields(:push_mutations) import_fields(:push_mutations)
import_fields(:activity_setting_mutations) import_fields(:activity_setting_mutations)
import_fields(:auth_application_mutations)
end end
@desc """ @desc """

View file

@ -0,0 +1,61 @@
defmodule Mobilizon.GraphQL.Schema.AuthApplicationType do
@moduledoc """
Schema representation for an auth application
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Application
@desc "An application"
object :auth_application do
field(:name, :string)
field(:client_id, :string)
field(:scopes, :string)
field(:website, :string)
end
@desc "An application"
object :auth_application_token do
field(:id, :id)
field(:inserted_at, :string)
field(:last_used_at, :string)
field(:application, :auth_application)
end
@desc "The informations returned after authorization"
object :application_code_and_state do
field(:code, :string)
field(:state, :string)
end
object :auth_application_queries do
@desc "Get an application"
field :auth_application, :auth_application do
arg(:client_id, non_null(:string), description: "The application's client_id")
resolve(&Application.get_application/3)
end
end
object :auth_application_mutations do
@desc "Authorize an application"
field :authorize_application, :application_code_and_state do
arg(:client_id, non_null(:string), description: "The application's client_id")
arg(:redirect_uri, non_null(:string),
description: "The URI to redirect to with the code and state"
)
arg(:scope, :string, description: "The scope for the authorization")
arg(:state, :string,
description: "A state parameter to check that the request wasn't altered"
)
resolve(&Application.authorize/3)
end
field :revoke_application_token, :deleted_object do
arg(:app_token_id, non_null(:string), description: "The application token's ID")
resolve(&Application.revoke_application_token/3)
end
end
end

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
import Absinthe.Resolution.Helpers, only: [dataloader: 2] import Absinthe.Resolution.Helpers, only: [dataloader: 2]
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Media, User} alias Mobilizon.GraphQL.Resolvers.{Application, Media, User}
alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
@ -161,6 +161,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
resolve: &ActivitySettings.user_activity_settings/3, resolve: &ActivitySettings.user_activity_settings/3,
description: "The user's activity settings" description: "The user's activity settings"
) )
field(:auth_authorized_applications, list_of(:auth_application_token),
resolve: &Application.get_user_applications/3,
description: "The user's authorized authentication apps"
)
end end
@desc "The list of roles an user can have" @desc "The list of roles an user can have"

View file

@ -0,0 +1,258 @@
defmodule Mobilizon.Applications do
@moduledoc """
The Applications context.
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Mobilizon.Applications.Application
alias Mobilizon.Storage.Repo
@doc """
Returns the list of applications.
## Examples
iex> list_applications()
[%Application{}, ...]
"""
def list_applications do
Repo.all(Application)
end
@doc """
Gets a single application.
Raises `Ecto.NoResultsError` if the Application does not exist.
## Examples
iex> get_application!(123)
%Application{}
iex> get_application!(456)
** (Ecto.NoResultsError)
"""
def get_application!(id), do: Repo.get!(Application, id)
@doc """
Gets a single application.
Returns nil if the Application does not exist.
## Examples
iex> get_application_by_client_id(123)
%Application{}
iex> get_application_by_client_id(456)
nil
"""
def get_application_by_client_id(client_id), do: Repo.get_by(Application, client_id: client_id)
@doc """
Creates a application.
## Examples
iex> create_application(%{field: value})
{:ok, %Application{}}
iex> create_application(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_application(attrs \\ %{}) do
%Application{}
|> Application.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a application.
## Examples
iex> update_application(application, %{field: new_value})
{:ok, %Application{}}
iex> update_application(application, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_application(%Application{} = application, attrs) do
application
|> Application.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a application.
## Examples
iex> delete_application(application)
{:ok, %Application{}}
iex> delete_application(application)
{:error, %Ecto.Changeset{}}
"""
def delete_application(%Application{} = application) do
Repo.delete(application)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking application changes.
## Examples
iex> change_application(application)
%Ecto.Changeset{data: %Application{}}
"""
def change_application(%Application{} = application, attrs \\ %{}) do
Application.changeset(application, attrs)
end
alias Mobilizon.Applications.ApplicationToken
@doc """
Returns the list of application_tokens.
## Examples
iex> list_application_tokens()
[%ApplicationToken{}, ...]
"""
def list_application_tokens do
Repo.all(ApplicationToken)
end
@doc """
Returns the list of application tokens for a given user_id
"""
def list_application_tokens_for_user_id(user_id) do
ApplicationToken
|> where(user_id: ^user_id)
|> where([at], is_nil(at.authorization_code))
|> preload(:application)
|> Repo.all()
end
@doc """
Gets a single application_token.
Raises `Ecto.NoResultsError` if the Application token does not exist.
## Examples
iex> get_application_token!(123)
%ApplicationToken{}
iex> get_application_token!(456)
** (Ecto.NoResultsError)
"""
def get_application_token!(id), do: Repo.get!(ApplicationToken, id)
@doc """
Gets a single application_token.
## Examples
iex> get_application_token(123)
%ApplicationToken{}
iex> get_application_token(456)
nil
"""
def get_application_token(application_token_id),
do: Repo.get(ApplicationToken, application_token_id)
def get_application_token(app_id, user_id),
do: Repo.get_by(ApplicationToken, application_id: app_id, user_id: user_id)
def get_application_token_by_authorization_code(code),
do: Repo.get_by(ApplicationToken, authorization_code: code)
@doc """
Creates a application_token.
## Examples
iex> create_application_token(%{field: value})
{:ok, %ApplicationToken{}}
iex> create_application_token(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_application_token(attrs \\ %{}) do
%ApplicationToken{}
|> ApplicationToken.changeset(attrs)
|> Repo.insert(on_conflict: :replace_all, conflict_target: [:user_id, :application_id])
end
@doc """
Updates a application_token.
## Examples
iex> update_application_token(application_token, %{field: new_value})
{:ok, %ApplicationToken{}}
iex> update_application_token(application_token, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_application_token(%ApplicationToken{} = application_token, attrs) do
application_token
|> ApplicationToken.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a application_token.
## Examples
iex> delete_application_token(application_token)
{:ok, %ApplicationToken{}}
iex> delete_application_token(application_token)
{:error, %Ecto.Changeset{}}
"""
def delete_application_token(%ApplicationToken{} = application_token) do
Repo.delete(application_token)
end
def revoke_application_token(%ApplicationToken{id: app_token_id} = application_token) do
Multi.new()
|> Multi.delete_all(
:delete_guardian_tokens,
from(gt in "guardian_tokens", where: gt.sub == ^"AppToken:#{app_token_id}")
)
|> Multi.delete(:delete_app_token, application_token)
|> Repo.transaction()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking application_token changes.
## Examples
iex> change_application_token(application_token)
%Ecto.Changeset{data: %ApplicationToken{}}
"""
def change_application_token(%ApplicationToken{} = application_token, attrs \\ %{}) do
ApplicationToken.changeset(application_token, attrs)
end
end

View file

@ -0,0 +1,32 @@
defmodule Mobilizon.Applications.Application do
@moduledoc """
Module representing an application
"""
use Ecto.Schema
import Ecto.Changeset
@required_attrs [:name, :client_id, :client_secret, :redirect_uris]
@optional_attrs [:scopes, :website, :owner_type, :owner_id]
@attrs @required_attrs ++ @optional_attrs
schema "applications" do
field(:name, :string)
field(:client_id, :string)
field(:client_secret, :string)
field(:redirect_uris, :string)
field(:scopes, :string)
field(:website, :string)
field(:owner_type, :string)
field(:owner_id, :integer)
timestamps()
end
@doc false
def changeset(application, attrs) do
application
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
end
end

View file

@ -0,0 +1,26 @@
defmodule Mobilizon.Applications.ApplicationToken do
@moduledoc """
Module representing an application token
"""
use Ecto.Schema
import Ecto.Changeset
schema "application_tokens" do
belongs_to(:user, Mobilizon.Users.User)
belongs_to(:application, Mobilizon.Applications.Application)
field(:authorization_code, :string)
timestamps()
end
@required_attrs [:user_id, :application_id]
@optional_attrs [:authorization_code]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(application_token, attrs) do
application_token
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
end
end

View file

@ -0,0 +1,130 @@
defmodule Mobilizon.Service.Auth.Applications do
@moduledoc """
Module to handle applications management
"""
alias Mobilizon.Applications
alias Mobilizon.Applications.{Application, ApplicationToken}
alias Mobilizon.Service.Auth.Authenticator
@app_access_tokens_ttl {8, :hour}
@app_refresh_tokens_ttl {26, :week}
@type access_token_details :: %{
required(:access_token) => String.t(),
required(:expires_in) => pos_integer(),
required(:refresh_token) => String.t(),
required(:refresh_token_expires_in) => pos_integer(),
required(:scope) => nil,
required(:token_type) => String.t()
}
def create(name, redirect_uris, scopes, website) do
client_id = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42)
client_secret = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42)
Applications.create_application(%{
name: name,
redirect_uris: redirect_uris,
scopes: scopes,
website: website,
client_id: client_id,
client_secret: client_secret
})
end
@spec autorize(String.t(), String.t(), String.t(), integer()) ::
{:ok, String.t()}
| {:error, :application_not_found}
| {:error, :redirect_uri_not_in_allowed}
def autorize(client_id, redirect_uri, _scope, user_id) do
with %Application{redirect_uris: redirect_uris, id: app_id} <-
Applications.get_application_by_client_id(client_id),
{:redirect_uri, true} <-
{:redirect_uri, redirect_uri in String.split(redirect_uris, "\n")},
code <- :crypto.strong_rand_bytes(16) |> Base.encode64() |> binary_part(0, 16),
{:ok, %ApplicationToken{}} <-
Applications.create_application_token(%{
user_id: user_id,
application_id: app_id,
authorization_code: code
}) do
{:ok, code}
else
nil ->
{:error, :application_not_found}
{:redirect_uri, _} ->
{:error, :redirect_uri_not_in_allowed}
end
end
@spec generate_access_token(String.t(), String.t(), String.t(), String.t()) ::
{:ok, access_token_details()}
| {:error,
:application_not_found
| :redirect_uri_not_in_allowed
| :provided_code_does_not_match
| :invalid_client_secret
| :app_token_not_found
| any()}
def generate_access_token(client_id, client_secret, code, redirect_uri) do
with {:application,
%Application{
id: application_id,
client_secret: app_client_secret,
scopes: scopes,
redirect_uris: redirect_uris
}} <-
{:application, Applications.get_application_by_client_id(client_id)},
{:redirect_uri, true} <-
{:redirect_uri, redirect_uri in String.split(redirect_uris, "\n")},
{:app_token, %ApplicationToken{} = app_token} <-
{:app_token, Applications.get_application_token_by_authorization_code(code)},
{:ok, %ApplicationToken{application_id: application_id_from_token} = app_token} <-
Applications.update_application_token(app_token, %{authorization_code: nil}),
{:same_app, true} <- {:same_app, application_id === application_id_from_token},
{:same_client_secret, true} <- {:same_client_secret, app_client_secret == client_secret},
{:ok, access_token} <-
Authenticator.generate_access_token(app_token, @app_access_tokens_ttl),
{:ok, refresh_token} <-
Authenticator.generate_refresh_token(app_token, @app_refresh_tokens_ttl) do
{:ok,
%{
access_token: access_token,
expires_in: ttl_to_seconds(@app_access_tokens_ttl),
refresh_token: refresh_token,
refresh_token_expires_in: ttl_to_seconds(@app_refresh_tokens_ttl),
scope: scopes,
token_type: "bearer"
}}
else
{:application, nil} ->
{:error, :application_not_found}
{:same_app, false} ->
{:error, :provided_code_does_not_match}
{:same_client_secret, _} ->
{:error, :invalid_client_secret}
{:redirect_uri, _} ->
{:error, :redirect_uri_not_in_allowed}
{:app_token, _} ->
{:error, :app_token_not_found}
{:error, err} ->
{:error, err}
end
end
def revoke_application_token(%ApplicationToken{} = app_token) do
Applications.revoke_application_token(app_token)
end
@spec ttl_to_seconds({pos_integer(), :second | :minute | :hour | :week}) :: pos_integer()
defp ttl_to_seconds({value, :second}), do: value
defp ttl_to_seconds({value, :minute}), do: value * 60
defp ttl_to_seconds({value, :hour}), do: value * 3600
defp ttl_to_seconds({value, :week}), do: value * 604_800
end

View file

@ -17,6 +17,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do
required(:user) => User.t() required(:user) => User.t()
} }
@type ttl :: {
pos_integer(),
:second | :minute | :hour | :week
}
def implementation do def implementation do
Mobilizon.Config.get( Mobilizon.Config.get(
Mobilizon.Service.Auth.Authenticator, Mobilizon.Service.Auth.Authenticator,
@ -55,7 +60,7 @@ defmodule Mobilizon.Service.Auth.Authenticator do
@doc """ @doc """
Generates access token and refresh token for an user. Generates access token and refresh token for an user.
""" """
@spec generate_tokens(User.t()) :: {:ok, tokens} @spec generate_tokens(User.t() | ApplicationToken.t()) :: {:ok, tokens} | {:error, any()}
def generate_tokens(user) do def generate_tokens(user) do
with {:ok, access_token} <- generate_access_token(user), with {:ok, access_token} <- generate_access_token(user),
{:ok, refresh_token} <- generate_refresh_token(user) do {:ok, refresh_token} <- generate_refresh_token(user) do
@ -66,10 +71,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do
@doc """ @doc """
Generates access token for an user. Generates access token for an user.
""" """
@spec generate_access_token(User.t()) :: {:ok, String.t()} @spec generate_access_token(User.t() | ApplicationToken.t(), ttl() | nil) ::
def generate_access_token(user) do {:ok, String.t()} | {:error, any()}
def generate_access_token(user, ttl \\ nil) do
with {:ok, access_token, _claims} <- with {:ok, access_token, _claims} <-
Guardian.encode_and_sign(user, %{}, token_type: "access") do Guardian.encode_and_sign(user, %{}, token_type: "access", ttl: ttl) do
{:ok, access_token} {:ok, access_token}
end end
end end
@ -77,10 +83,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do
@doc """ @doc """
Generates refresh token for an user. Generates refresh token for an user.
""" """
@spec generate_refresh_token(User.t()) :: {:ok, String.t()} @spec generate_refresh_token(User.t() | ApplicationToken.t(), ttl() | nil) ::
def generate_refresh_token(user) do {:ok, String.t()} | {:error, any()}
def generate_refresh_token(user, ttl \\ nil) do
with {:ok, refresh_token, _claims} <- with {:ok, refresh_token, _claims} <-
Guardian.encode_and_sign(user, %{}, token_type: "refresh") do Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: ttl) do
{:ok, refresh_token} {:ok, refresh_token}
end end
end end

View file

@ -6,6 +6,8 @@ defmodule Mobilizon.Web.Auth.Context do
import Plug.Conn import Plug.Conn
alias Mobilizon.Applications.Application, as: AuthApplication
alias Mobilizon.Applications.ApplicationToken
alias Mobilizon.Users.User alias Mobilizon.Users.User
@spec init(Plug.opts()) :: Plug.opts() @spec init(Plug.opts()) :: Plug.opts()
@ -28,18 +30,13 @@ defmodule Mobilizon.Web.Auth.Context do
{conn, context} = {conn, context} =
case Guardian.Plug.current_resource(conn) do case Guardian.Plug.current_resource(conn) do
%User{id: user_id, email: user_email} = user -> %User{} = user ->
if Application.get_env(:sentry, :dsn) != nil do set_user_context({conn, context}, user)
Sentry.Context.set_user_context(%{
id: user_id,
email: user_email,
ip_address: context.ip
})
end
context = Map.put(context, :current_user, user) %ApplicationToken{user: %User{} = user} = app_token ->
conn = assign(conn, :user_locale, user.locale) conn
{conn, context} |> set_app_token_context(context, app_token)
|> set_user_context(user)
nil -> nil ->
{conn, context} {conn, context}
@ -49,4 +46,35 @@ defmodule Mobilizon.Web.Auth.Context do
put_private(conn, :absinthe, %{context: context}) put_private(conn, :absinthe, %{context: context})
end end
defp set_user_context({conn, context}, %User{id: user_id, email: user_email} = user) do
if Application.get_env(:sentry, :dsn) != nil do
Sentry.Context.set_user_context(%{
id: user_id,
email: user_email,
ip_address: context.ip
})
end
context = Map.put(context, :current_user, user)
conn = assign(conn, :user_locale, user.locale)
{conn, context}
end
defp set_app_token_context(
conn,
context,
%ApplicationToken{application: %AuthApplication{client_id: client_id} = app} = app_token
) do
if Application.get_env(:sentry, :dsn) != nil do
Sentry.Context.set_user_context(%{
app_token_client_id: client_id
})
end
context =
context |> Map.put(:current_auth_app_token, app_token) |> Map.put(:current_auth_app, app)
{conn, context}
end
end end

View file

@ -10,14 +10,19 @@ defmodule Mobilizon.Web.Auth.Guardian do
user: [:base] user: [:base]
} }
alias Mobilizon.Users alias Mobilizon.{Applications, Users}
alias Mobilizon.Applications.ApplicationToken
alias Mobilizon.Users.User alias Mobilizon.Users.User
require Logger require Logger
@spec subject_for_token(any(), any()) :: {:ok, String.t()} | {:error, :unknown_resource} @spec subject_for_token(any(), any()) :: {:ok, String.t()} | {:error, :unknown_resource}
def subject_for_token(%User{} = user, _claims) do def subject_for_token(%User{id: user_id}, _claims) do
{:ok, "User:" <> to_string(user.id)} {:ok, "User:" <> to_string(user_id)}
end
def subject_for_token(%ApplicationToken{id: app_token_id}, _claims) do
{:ok, "AppToken:" <> to_string(app_token_id)}
end end
def subject_for_token(_, _) do def subject_for_token(_, _) do
@ -42,6 +47,25 @@ defmodule Mobilizon.Web.Auth.Guardian do
end end
end end
def resource_from_claims(%{"sub" => "AppToken:" <> id_str}) do
Logger.debug(fn -> "Receiving claim for app token #{id_str}" end)
try do
case Integer.parse(id_str) do
{id, ""} ->
application_token = Applications.get_application_token!(id)
user = Users.get_user_with_actors!(application_token.user_id)
application = Applications.get_application!(application_token.application_id)
{:ok, application_token |> Map.put(:user, user) |> Map.put(:application, application)}
_ ->
{:error, :invalid_id}
end
rescue
Ecto.NoResultsError -> {:error, :no_result}
end
end
def resource_from_claims(_) do def resource_from_claims(_) do
{:error, :no_claims} {:error, :no_claims}
end end

View file

@ -4,19 +4,16 @@ defmodule Mobilizon.Web.GraphQLSocket do
use Absinthe.Phoenix.Socket, use Absinthe.Phoenix.Socket,
schema: Mobilizon.GraphQL.Schema schema: Mobilizon.GraphQL.Schema
alias Mobilizon.Applications.Application, as: AuthApplication
alias Mobilizon.Applications.ApplicationToken
alias Mobilizon.Users.User alias Mobilizon.Users.User
@spec connect(map, Phoenix.Socket.t()) :: {:ok, Phoenix.Socket.t()} | :error @spec connect(map, Phoenix.Socket.t()) :: {:ok, Phoenix.Socket.t()} | :error
def connect(%{"token" => token}, socket) do def connect(%{"token" => token}, socket) do
with {:ok, authed_socket} <- with {:ok, authed_socket} <-
Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token), Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token),
%User{} = user <- Guardian.Phoenix.Socket.current_resource(authed_socket) do resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
authed_socket = set_context(authed_socket, resource)
Absinthe.Phoenix.Socket.put_options(socket,
context: %{
current_user: user
}
)
{:ok, authed_socket} {:ok, authed_socket}
else else
@ -29,4 +26,27 @@ defmodule Mobilizon.Web.GraphQLSocket do
@spec id(any) :: nil @spec id(any) :: nil
def id(_socket), do: nil def id(_socket), do: nil
@spec set_context(Phoenix.Socket.t(), User.t() | ApplicationToken.t()) :: Phoenix.Socket.t()
defp set_context(socket, %User{} = user) do
Absinthe.Phoenix.Socket.put_options(socket,
context: %{
current_user: user
}
)
end
defp set_context(
socket,
%ApplicationToken{user: %User{} = user, application: %AuthApplication{} = app} =
app_token
) do
Absinthe.Phoenix.Socket.put_options(socket,
context: %{
current_auth_app_token: app_token,
current_auth_app: app,
current_user: user
}
)
end
end end

View file

@ -0,0 +1,130 @@
defmodule Mobilizon.Web.ApplicationController do
use Mobilizon.Web, :controller
alias Mobilizon.Applications.Application
alias Mobilizon.Service.Auth.Applications
plug(:put_layout, false)
import Mobilizon.Web.Gettext, only: [dgettext: 2]
@out_of_band_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
@doc """
Create an application
"""
@spec create_application(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create_application(conn, %{"name" => name, "redirect_uris" => redirect_uris} = args) do
case Applications.create(
name,
redirect_uris,
Map.get(args, "scopes"),
Map.get(args, "website")
) do
{:ok, %Application{} = app} ->
json(
conn,
Map.take(app, [:name, :website, :redirect_uris, :client_id, :client_secret, :scope])
)
{:error, _error} ->
send_resp(
conn,
500,
dgettext(
"errors",
"Impossible to create application."
)
)
end
end
def create_application(conn, _args) do
send_resp(
conn,
400,
dgettext(
"errors",
"Both name and redirect_uri parameters are required to create an application"
)
)
end
@doc """
Authorize
"""
@spec authorize(Plug.Conn.t(), map()) :: Plug.Conn.t()
def authorize(
conn,
_args
) do
conn = fetch_query_params(conn)
client_id = conn.query_params["client_id"]
redirect_uri = conn.query_params["redirect_uri"]
state = conn.query_params["state"]
if is_binary(client_id) and is_binary(redirect_uri) and is_binary(state) do
redirect(conn,
to:
Routes.page_path(conn, :authorize,
client_id: client_id,
redirect_uri: redirect_uri,
scope: conn.query_params["scope"],
state: state
)
)
else
send_resp(
conn,
400,
dgettext(
"errors",
"You need to specify client_id, redirect_uri and state to autorize an application"
)
)
end
end
@spec generate_access_token(Plug.Conn.t(), map()) :: Plug.Conn.t()
def generate_access_token(conn, %{
"client_id" => client_id,
"client_secret" => client_secret,
"code" => code,
"redirect_uri" => redirect_uri
}) do
case Applications.generate_access_token(client_id, client_secret, code, redirect_uri) do
{:ok, token} ->
if redirect_uri != @out_of_band_redirect_uri do
redirect(conn, external: generate_redirect_with_query_params(redirect_uri, token))
else
json(conn, token)
end
{:error, :application_not_found} ->
send_resp(conn, 400, dgettext("errors", "No application was found with this client_id"))
{:error, :redirect_uri_not_in_allowed} ->
send_resp(conn, 400, dgettext("errors", "This redirect URI is not allowed"))
{:error, :invalid_or_expired} ->
send_resp(conn, 400, dgettext("errors", "The provided code is invalid or expired"))
{:error, :invalid_client_id} ->
send_resp(
conn,
400,
dgettext("errors", "The provided client_id does not match the provided code")
)
{:error, :invalid_client_secret} ->
send_resp(conn, 400, dgettext("errors", "The provided client_secret is invalid"))
{:error, :user_not_found} ->
send_resp(conn, 400, dgettext("errors", "The user for this code was not found"))
end
end
@spec generate_redirect_with_query_params(String.t(), map()) :: String.t()
defp generate_redirect_with_query_params(redirect_uri, query_params) do
redirect_uri |> URI.parse() |> URI.merge("?" <> URI.encode_query(query_params)) |> to_string()
end
end

View file

@ -121,6 +121,9 @@ defmodule Mobilizon.Web.PageController do
end end
end end
@spec authorize(Plug.Conn.t(), any) :: Plug.Conn.t()
def authorize(conn, _params), do: render(conn, :index)
@spec handle_collection_route(Plug.Conn.t(), collections()) :: Plug.Conn.t() @spec handle_collection_route(Plug.Conn.t(), collections()) :: Plug.Conn.t()
defp handle_collection_route(conn, collection) do defp handle_collection_route(conn, collection) do
case get_format(conn) do case get_format(conn) do

View file

@ -205,6 +205,11 @@ defmodule Mobilizon.Web.Router do
# Also possible CSRF issue # Also possible CSRF issue
get("/auth/:provider/callback", AuthController, :callback) get("/auth/:provider/callback", AuthController, :callback)
post("/auth/:provider/callback", AuthController, :callback) post("/auth/:provider/callback", AuthController, :callback)
post("/apps", ApplicationController, :create_application)
get("/oauth/authorize", ApplicationController, :authorize)
post("/oauth/token", ApplicationController, :generate_access_token)
get("/oauth/autorize_approve", PageController, :authorize)
end end
scope "/proxy/", Mobilizon.Web do scope "/proxy/", Mobilizon.Web do

View file

@ -0,0 +1,20 @@
defmodule Mobilizon.Repo.Migrations.CreateApplications do
use Ecto.Migration
def change do
create table(:applications) do
add(:name, :string, null: false)
add(:client_id, :string, null: false)
add(:client_secret, :string, null: false)
add(:redirect_uris, :string, null: false)
add(:scopes, :string, null: true)
add(:website, :string, null: true)
add(:owner_type, :string, null: true)
add(:owner_id, :integer, null: true)
timestamps()
end
create(index(:applications, [:owner_id, :owner_type]))
end
end

View file

@ -0,0 +1,14 @@
defmodule Mobilizon.Repo.Migrations.CreateApplicationTokens do
use Ecto.Migration
def change do
create table(:application_tokens) do
add(:user_id, references(:users, on_delete: :delete_all), null: false)
add(:application_id, references(:applications, on_delete: :delete_all), null: false)
add(:authorization_code, :string, null: true)
timestamps()
end
create(unique_index(:application_tokens, [:user_id, :application_id]))
end
end

View file

@ -0,0 +1,146 @@
defmodule Mobilizon.ApplicationsTest do
use Mobilizon.DataCase
alias Mobilizon.Applications
describe "applications" do
alias Mobilizon.Applications.Application
import Mobilizon.ApplicationsFixtures
@invalid_attrs %{name: nil}
test "list_applications/0 returns all applications" do
application = application_fixture()
assert Applications.list_applications() == [application]
end
test "get_application!/1 returns the application with given id" do
application = application_fixture()
assert Applications.get_application!(application.id) == application
end
test "create_application/1 with valid data creates a application" do
valid_attrs = %{
name: "some name",
client_id: "hello",
client_secret: "secret",
redirect_uris: "somewhere\nelse"
}
assert {:ok, %Application{} = application} = Applications.create_application(valid_attrs)
assert application.name == "some name"
assert application.client_id == "hello"
assert application.client_secret == "secret"
assert application.redirect_uris == "somewhere\nelse"
end
test "create_application/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Applications.create_application(@invalid_attrs)
end
test "update_application/2 with valid data updates the application" do
application = application_fixture()
update_attrs = %{name: "some updated name"}
assert {:ok, %Application{} = application} =
Applications.update_application(application, update_attrs)
assert application.name == "some updated name"
end
test "update_application/2 with invalid data returns error changeset" do
application = application_fixture()
assert {:error, %Ecto.Changeset{}} =
Applications.update_application(application, @invalid_attrs)
assert application == Applications.get_application!(application.id)
end
test "delete_application/1 deletes the application" do
application = application_fixture()
assert {:ok, %Application{}} = Applications.delete_application(application)
assert_raise Ecto.NoResultsError, fn -> Applications.get_application!(application.id) end
end
test "change_application/1 returns a application changeset" do
application = application_fixture()
assert %Ecto.Changeset{} = Applications.change_application(application)
end
end
describe "application_tokens" do
alias Mobilizon.Applications.ApplicationToken
import Mobilizon.ApplicationsFixtures
import Mobilizon.Factory
@invalid_attrs %{user_id: nil}
test "list_application_tokens/0 returns all application_tokens" do
application_token = application_token_fixture()
assert Applications.list_application_tokens() == [application_token]
end
test "get_application_token!/1 returns the application_token with given id" do
application_token = application_token_fixture()
assert Applications.get_application_token!(application_token.id) == application_token
end
test "create_application_token/1 with valid data creates a application_token" do
user = insert(:user)
application = application_fixture()
valid_attrs = %{
user_id: user.id,
application_id: application.id,
authorization_code: "hey hello"
}
assert {:ok, %ApplicationToken{} = application_token} =
Applications.create_application_token(valid_attrs)
assert application_token.user_id == user.id
assert application_token.application_id == application.id
assert application_token.authorization_code == "hey hello"
end
test "create_application_token/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Applications.create_application_token(@invalid_attrs)
end
test "update_application_token/2 with valid data updates the application_token" do
application_token = application_token_fixture()
update_attrs = %{authorization_code: nil}
assert {:ok, %ApplicationToken{} = application_token} =
Applications.update_application_token(application_token, update_attrs)
assert is_nil(application_token.authorization_code)
end
test "update_application_token/2 with invalid data returns error changeset" do
application_token = application_token_fixture()
assert {:error, %Ecto.Changeset{}} =
Applications.update_application_token(application_token, @invalid_attrs)
assert application_token == Applications.get_application_token!(application_token.id)
end
test "delete_application_token/1 deletes the application_token" do
application_token = application_token_fixture()
assert {:ok, %ApplicationToken{}} = Applications.delete_application_token(application_token)
assert_raise Ecto.NoResultsError, fn ->
Applications.get_application_token!(application_token.id)
end
end
test "change_application_token/1 returns a application_token changeset" do
application_token = application_token_fixture()
assert %Ecto.Changeset{} = Applications.change_application_token(application_token)
end
end
end

View file

@ -0,0 +1,43 @@
defmodule Mobilizon.ApplicationsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Mobilizon.Applications` context.
"""
import Mobilizon.Factory
@doc """
Generate a application.
"""
def application_fixture(attrs \\ %{}) do
{:ok, application} =
attrs
|> Enum.into(%{
name: "some name",
client_id: "hello",
client_secret: "secret",
redirect_uris: "somewhere\nelse"
})
|> Mobilizon.Applications.create_application()
application
end
@doc """
Generate a application_token.
"""
def application_token_fixture(attrs \\ %{}) do
user = insert(:user)
{:ok, application_token} =
attrs
|> Enum.into(%{
application_id: application_fixture().id,
user_id: user.id,
authorization_code: "some code"
})
|> Mobilizon.Applications.create_application_token()
application_token
end
end