Working start of an OAuth flow
This commit is contained in:
parent
a8d1450763
commit
1017c71ba1
|
@ -95,5 +95,5 @@ class PostAttachment(StatorModel):
|
||||||
"width": self.width,
|
"width": self.width,
|
||||||
"height": self.height,
|
"height": self.height,
|
||||||
"mediaType": self.mimetype,
|
"mediaType": self.mimetype,
|
||||||
"http://joinmastodon.org/ns#focalPoint": [0.5, 0.5],
|
"http://joinmastodon.org/ns#focalPoint": [0, 0],
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from api.models import Application, Token
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Application)
|
||||||
|
class ApplicationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "name", "website", "created"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Token)
|
||||||
|
class TokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "user", "application", "created"]
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "api"
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Generated by Django 4.1.3 on 2022-12-11 03:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0003_identity_followers_etc"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Application",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("client_id", models.CharField(max_length=500)),
|
||||||
|
("client_secret", models.CharField(max_length=500)),
|
||||||
|
("redirect_uris", models.TextField()),
|
||||||
|
("scopes", models.TextField()),
|
||||||
|
("name", models.CharField(max_length=500)),
|
||||||
|
("website", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Token",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("token", models.CharField(max_length=500)),
|
||||||
|
("code", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
("scopes", models.JSONField()),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"application",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="tokens",
|
||||||
|
to="api.application",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"identity",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="tokens",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="tokens",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,2 @@
|
||||||
|
from .application import Application # noqa
|
||||||
|
from .token import Token # noqa
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Application(models.Model):
|
||||||
|
"""
|
||||||
|
OAuth applications
|
||||||
|
"""
|
||||||
|
|
||||||
|
client_id = models.CharField(max_length=500)
|
||||||
|
client_secret = models.CharField(max_length=500)
|
||||||
|
|
||||||
|
redirect_uris = models.TextField()
|
||||||
|
scopes = models.TextField()
|
||||||
|
|
||||||
|
name = models.CharField(max_length=500)
|
||||||
|
website = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Token(models.Model):
|
||||||
|
"""
|
||||||
|
An (access) token to call the API with.
|
||||||
|
|
||||||
|
Can be either tied to a user, or app-level only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
application = models.ForeignKey(
|
||||||
|
"api.Application",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="tokens",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"users.User",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="tokens",
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="tokens",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = models.CharField(max_length=500)
|
||||||
|
code = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
|
scopes = models.JSONField()
|
||||||
|
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
|
@ -0,0 +1,20 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ninja.parser import Parser
|
||||||
|
|
||||||
|
|
||||||
|
class FormOrJsonParser(Parser):
|
||||||
|
"""
|
||||||
|
If there's form data in a request, makes it into a JSON dict.
|
||||||
|
This is needed as the Mastodon API allows form data OR json body as input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse_body(self, request):
|
||||||
|
# Did they submit JSON?
|
||||||
|
if request.content_type == "application/json":
|
||||||
|
return json.loads(request.body)
|
||||||
|
# Fall back to form data
|
||||||
|
value = {}
|
||||||
|
for key, item in request.POST.items():
|
||||||
|
value[key] = item
|
||||||
|
return value
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .apps import * # noqa
|
||||||
|
from .base import api # noqa
|
||||||
|
from .instance import * # noqa
|
|
@ -0,0 +1,37 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from ninja import Field, Schema
|
||||||
|
|
||||||
|
from ..models import Application
|
||||||
|
from .base import api
|
||||||
|
|
||||||
|
|
||||||
|
class CreateApplicationSchema(Schema):
|
||||||
|
client_name: str
|
||||||
|
redirect_uris: str
|
||||||
|
scopes: None | str = None
|
||||||
|
website: None | str = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationSchema(Schema):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
website: str | None
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
redirect_uri: str = Field(alias="redirect_uris")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/v1/apps", response=ApplicationSchema)
|
||||||
|
def add_app(request, details: CreateApplicationSchema):
|
||||||
|
client_id = "tk-" + secrets.token_urlsafe(16)
|
||||||
|
client_secret = secrets.token_urlsafe(40)
|
||||||
|
application = Application.objects.create(
|
||||||
|
name=details.client_name,
|
||||||
|
website=details.website,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
redirect_uris=details.redirect_uris,
|
||||||
|
scopes=details.scopes or "read",
|
||||||
|
)
|
||||||
|
return application
|
|
@ -0,0 +1,5 @@
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
from api.parser import FormOrJsonParser
|
||||||
|
|
||||||
|
api = NinjaAPI(parser=FormOrJsonParser())
|
|
@ -0,0 +1,56 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from activities.models import Post
|
||||||
|
from core.models import Config
|
||||||
|
from takahe import __version__
|
||||||
|
from users.models import Domain, Identity
|
||||||
|
|
||||||
|
from .base import api
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/v1/instance")
|
||||||
|
@api.get("/v1/instance/")
|
||||||
|
def instance_info(request):
|
||||||
|
return {
|
||||||
|
"uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN),
|
||||||
|
"title": Config.system.site_name,
|
||||||
|
"short_description": "",
|
||||||
|
"description": "",
|
||||||
|
"email": "",
|
||||||
|
"version": __version__,
|
||||||
|
"urls": {},
|
||||||
|
"stats": {
|
||||||
|
"user_count": Identity.objects.filter(local=True).count(),
|
||||||
|
"status_count": Post.objects.filter(local=True).count(),
|
||||||
|
"domain_count": Domain.objects.count(),
|
||||||
|
},
|
||||||
|
"thumbnail": Config.system.site_banner,
|
||||||
|
"languages": ["en"],
|
||||||
|
"registrations": (
|
||||||
|
Config.system.signup_allowed and not Config.system.signup_invite_only
|
||||||
|
),
|
||||||
|
"approval_required": False,
|
||||||
|
"invites_enabled": False,
|
||||||
|
"configuration": {
|
||||||
|
"accounts": {},
|
||||||
|
"statuses": {
|
||||||
|
"max_characters": Config.system.post_length,
|
||||||
|
"max_media_attachments": 4,
|
||||||
|
"characters_reserved_per_url": 23,
|
||||||
|
},
|
||||||
|
"media_attachments": {
|
||||||
|
"supported_mime_types": [
|
||||||
|
"image/apng",
|
||||||
|
"image/avif",
|
||||||
|
"image/gif",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
],
|
||||||
|
"image_size_limit": (1024**2) * 10,
|
||||||
|
"image_matrix_limit": 2000 * 2000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"contact_account": None,
|
||||||
|
"rules": [],
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import HttpResponseRedirect, JsonResponse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
|
from api.models import Application, Token
|
||||||
|
|
||||||
|
|
||||||
|
class OauthRedirect(HttpResponseRedirect):
|
||||||
|
def __init__(self, redirect_uri, key, value):
|
||||||
|
self.allowed_schemes = [urlparse(redirect_uri).scheme]
|
||||||
|
super().__init__(redirect_uri + f"?{key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationView(LoginRequiredMixin, TemplateView):
|
||||||
|
"""
|
||||||
|
Asks the user to authorize access.
|
||||||
|
|
||||||
|
Could maybe be a FormView, but things are weird enough we just handle the
|
||||||
|
POST manually.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "api/oauth_authorize.html"
|
||||||
|
|
||||||
|
def get_context_data(self):
|
||||||
|
redirect_uri = self.request.GET["redirect_uri"]
|
||||||
|
scope = self.request.GET.get("scope", "read")
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(
|
||||||
|
client_id=self.request.GET["client_id"]
|
||||||
|
)
|
||||||
|
except (Application.DoesNotExist, KeyError):
|
||||||
|
return OauthRedirect(redirect_uri, "error", "invalid_application")
|
||||||
|
return {
|
||||||
|
"application": application,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": scope,
|
||||||
|
"identities": self.request.user.identities.all(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
# Grab the application and other details again
|
||||||
|
redirect_uri = self.request.POST["redirect_uri"]
|
||||||
|
scope = self.request.POST["scope"]
|
||||||
|
application = Application.objects.get(client_id=self.request.POST["client_id"])
|
||||||
|
# Get the identity
|
||||||
|
identity = self.request.user.identities.get(pk=self.request.POST["identity"])
|
||||||
|
# Make a token
|
||||||
|
token = Token.objects.create(
|
||||||
|
application=application,
|
||||||
|
user=self.request.user,
|
||||||
|
identity=identity,
|
||||||
|
token=secrets.token_urlsafe(32),
|
||||||
|
code=secrets.token_urlsafe(16),
|
||||||
|
scopes=scope.split(),
|
||||||
|
)
|
||||||
|
# Redirect with the token's code
|
||||||
|
return OauthRedirect(redirect_uri, "code", token.code)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class TokenView(View):
|
||||||
|
def post(self, request):
|
||||||
|
grant_type = request.POST["grant_type"]
|
||||||
|
scopes = set(self.request.POST.get("scope", "read").split())
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(
|
||||||
|
client_id=self.request.POST["client_id"]
|
||||||
|
)
|
||||||
|
except (Application.DoesNotExist, KeyError):
|
||||||
|
return JsonResponse({"error": "invalid_client_id"}, status=400)
|
||||||
|
# TODO: Implement client credentials flow
|
||||||
|
if grant_type == "client_credentials":
|
||||||
|
return JsonResponse({"error": "invalid_grant_type"}, status=400)
|
||||||
|
elif grant_type == "authorization_code":
|
||||||
|
code = request.POST["code"]
|
||||||
|
# Retrieve the token by code
|
||||||
|
# TODO: Check code expiry based on created date
|
||||||
|
try:
|
||||||
|
token = Token.objects.get(code=code, application=application)
|
||||||
|
except Token.DoesNotExist:
|
||||||
|
return JsonResponse({"error": "invalid_code"}, status=400)
|
||||||
|
# Verify the scopes match the token
|
||||||
|
if scopes != set(token.scopes):
|
||||||
|
return JsonResponse({"error": "invalid_scope"}, status=400)
|
||||||
|
# Update the token to remove its code
|
||||||
|
token.code = None
|
||||||
|
token.save()
|
||||||
|
# Return them the token
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"access_token": token.token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": " ".join(token.scopes),
|
||||||
|
"created_at": int(token.created.timestamp()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeTokenView(View):
|
||||||
|
pass
|
|
@ -3,7 +3,10 @@ blurhash-python~=1.1.3
|
||||||
cryptography~=38.0
|
cryptography~=38.0
|
||||||
dj_database_url~=1.0.0
|
dj_database_url~=1.0.0
|
||||||
django-cache-url~=3.4.2
|
django-cache-url~=3.4.2
|
||||||
|
django-cors-headers~=3.13.0
|
||||||
django-htmx~=1.13.0
|
django-htmx~=1.13.0
|
||||||
|
django-ninja~=0.19.1
|
||||||
|
django-oauth-toolkit~=2.2.0
|
||||||
django-storages[google,boto3]~=1.13.1
|
django-storages[google,boto3]~=1.13.1
|
||||||
django~=4.1
|
django~=4.1
|
||||||
email-validator~=1.3.0
|
email-validator~=1.3.0
|
||||||
|
|
|
@ -169,16 +169,19 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django_htmx",
|
"django_htmx",
|
||||||
|
"corsheaders",
|
||||||
"core",
|
"core",
|
||||||
"activities",
|
"activities",
|
||||||
"users",
|
"api",
|
||||||
"stator",
|
|
||||||
"mediaproxy",
|
"mediaproxy",
|
||||||
|
"stator",
|
||||||
|
"users",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"core.middleware.SentryTaggingMiddleware",
|
"core.middleware.SentryTaggingMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
@ -278,6 +281,7 @@ AUTO_ADMIN_EMAIL = SETUP.AUTO_ADMIN_EMAIL
|
||||||
|
|
||||||
STATOR_TOKEN = SETUP.STATOR_TOKEN
|
STATOR_TOKEN = SETUP.STATOR_TOKEN
|
||||||
|
|
||||||
|
CORS_ORIGIN_ALLOW_ALL = True # Temporary
|
||||||
CORS_ORIGIN_WHITELIST = SETUP.CORS_HOSTS
|
CORS_ORIGIN_WHITELIST = SETUP.CORS_HOSTS
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
CORS_PREFLIGHT_MAX_AGE = 604800
|
CORS_PREFLIGHT_MAX_AGE = 604800
|
||||||
|
@ -288,6 +292,7 @@ MEDIA_URL = SETUP.MEDIA_URL
|
||||||
MEDIA_ROOT = SETUP.MEDIA_ROOT
|
MEDIA_ROOT = SETUP.MEDIA_ROOT
|
||||||
MAIN_DOMAIN = SETUP.MAIN_DOMAIN
|
MAIN_DOMAIN = SETUP.MAIN_DOMAIN
|
||||||
|
|
||||||
|
|
||||||
if SETUP.USE_PROXY_HEADERS:
|
if SETUP.USE_PROXY_HEADERS:
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.urls import path, re_path
|
||||||
from django.views.static import serve
|
from django.views.static import serve
|
||||||
|
|
||||||
from activities.views import compose, explore, follows, posts, search, timelines
|
from activities.views import compose, explore, follows, posts, search, timelines
|
||||||
|
from api.views import api, oauth
|
||||||
from core import views as core
|
from core import views as core
|
||||||
from mediaproxy import views as mediaproxy
|
from mediaproxy import views as mediaproxy
|
||||||
from stator import views as stator
|
from stator import views as stator
|
||||||
|
@ -201,6 +202,11 @@ urlpatterns = [
|
||||||
path("actor/", activitypub.SystemActorView.as_view()),
|
path("actor/", activitypub.SystemActorView.as_view()),
|
||||||
path("actor/inbox/", activitypub.Inbox.as_view()),
|
path("actor/inbox/", activitypub.Inbox.as_view()),
|
||||||
path("inbox/", activitypub.Inbox.as_view(), name="shared_inbox"),
|
path("inbox/", activitypub.Inbox.as_view(), name="shared_inbox"),
|
||||||
|
# API/Oauth
|
||||||
|
path("api/", api.urls),
|
||||||
|
path("oauth/authorize", oauth.AuthorizationView.as_view()),
|
||||||
|
path("oauth/token", oauth.TokenView.as_view()),
|
||||||
|
path("oauth/revoke_token", oauth.RevokeTokenView.as_view()),
|
||||||
# Stator
|
# Stator
|
||||||
path(".stator/", stator.RequestRunner.as_view()),
|
path(".stator/", stator.RequestRunner.as_view()),
|
||||||
# Django admin
|
# Django admin
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Authorize {{ application.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not identities %}
|
||||||
|
<p>
|
||||||
|
You cannot give access to {{ application.name }} as you
|
||||||
|
have no identities yet. Log in via the website and create
|
||||||
|
at least one identity, then retry this process.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Authorize</legend>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label-input">
|
||||||
|
<label for="identity">Select Identity</label>
|
||||||
|
<select name="identity" id="identity">
|
||||||
|
{% for identity in identities %}
|
||||||
|
<option value="{{ identity.pk }}">{{ identity.handle }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>Do you want to give {{ application.name }} access to this identity?</p>
|
||||||
|
<p>It will have permission to: {{ scope }}</p>
|
||||||
|
<input type="hidden" name="client_id" value="{{ application.client_id }}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||||
|
<input type="hidden" name="scope" value="{{ scope }}">
|
||||||
|
</fieldset>
|
||||||
|
<div class="buttons">
|
||||||
|
<a href="#" class="secondary button left">Deny</a>
|
||||||
|
<button>Allow</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
|
@ -11,6 +11,7 @@
|
||||||
{% include "forms/_field.html" %}
|
{% include "forms/_field.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}" />
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<a href="{% url "trigger_reset" %}" class="secondary button left">Forgot Password</a>
|
<a href="{% url "trigger_reset" %}" class="secondary button left">Forgot Password</a>
|
||||||
<button>Login</button>
|
<button>Login</button>
|
||||||
|
|
Loading…
Reference in New Issue